diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0d87bca028c..aa46b21e811 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -139,10 +139,12 @@ fn build_initialize_params() -> serde_json::Value { /// One `AcpClient` per agent process. Multiple sessions can be created on the /// same client via repeated calls to [`session_new`](AcpClient::session_new). pub struct AcpClient { + teardown_supported: bool, + teardown_confirmed: bool, /// The agent child process (kept alive to prevent zombie). child: Child, /// Write end of the agent's stdin pipe. - stdin: ChildStdin, + stdin: Option, /// Framed reader over the agent's stdout pipe (line-oriented, bounded). /// Uses `LinesCodec::new_with_max_length` to enforce MAX_LINE_SIZE at the /// read level — prevents OOM from rogue agents writing infinite non-newline bytes. @@ -414,12 +416,53 @@ fn build_client_capabilities() -> serde_json::Value { } impl AcpClient { - /// Kill the agent subprocess and wait for it to exit (no zombies). - /// - /// `Drop` only calls `start_kill()` (sends SIGKILL but doesn't reap). - /// Call this when you need guaranteed cleanup — e.g., in `run_models` - /// before process exit. + /// Close the agent connection and drain output while it tears down its MCP + /// children. Escalate only if the cooperative owner does not exit in time. + /// An exit here is not a certificate for arbitrary third-party executors. pub async fn shutdown(&mut self) { + self.shutdown_with_grace(std::time::Duration::from_secs(20)) + .await; + } + + async fn shutdown_with_grace(&mut self, grace: std::time::Duration) { + if self.teardown_confirmed { + return; + } + let confirmed = if self.teardown_supported && self.stdin.is_some() { + matches!(tokio::time::timeout(grace, + self.send_request("_buzz/shutdown_v1", serde_json::json!({})) + ).await, Ok(Ok(ref result)) if result["v"] == 1 && result["ownedWorkStopped"] == true) + } else { + false + }; + drop(self.stdin.take()); + let graceful = tokio::time::timeout(grace, async { + loop { + tokio::select! { + status = self.child.wait() => return status, + // Without draining, an agent finishing a prompt can block + // on stdout and never reach its own connection cleanup. + line = self.reader.next() => { + if line.is_none() { + return self.child.wait().await; + } + } + } + } + }) + .await; + match graceful { + Ok(Ok(status)) => { + tracing::info!(%status, "agent connection closed and child reaped"); + if confirmed && status.success() { + self.teardown_confirmed = true; + crate::shutdown::child_confirmed(); + } + return; + } + Ok(Err(error)) => tracing::warn!(%error, "agent wait failed; teardown unconfirmed"), + Err(_) => tracing::warn!("agent graceful shutdown timed out; teardown unconfirmed"), + } // Kill the entire process group when possible. The child was spawned // with process_group(0), so its PID == its PGID. Killing the group // ensures subprocesses (MCP servers, tool processes) are cleaned up @@ -534,7 +577,10 @@ impl AcpClient { "codex" | "codex-acp" => Some(StandardAdapterKind::Codex), _ => None, }; + // Only the harness may write its final generation receipt. + cmd.env_remove("BUZZ_STOP_RECEIPT_PATH"); let mut child = cmd.spawn()?; + crate::shutdown::child_spawned(); let stdin = child .stdin @@ -546,8 +592,10 @@ impl AcpClient { .ok_or_else(|| AcpError::Protocol("failed to open agent stdout".into()))?; Ok(Self { + teardown_supported: false, + teardown_confirmed: false, child, - stdin, + stdin: Some(stdin), reader: FramedRead::new(stdout, LinesCodec::new_with_max_length(MAX_LINE_SIZE)), next_id: 0, pending_permission_id: None, @@ -613,6 +661,10 @@ impl AcpClient { // on ACP v2 ahead of the upstream ACP RFD. Revisit when that RFD merges. let params = build_initialize_params(); let result = self.send_request("initialize", params).await?; + self.teardown_supported = result + .pointer("/_meta/buzzOwnedWorkShutdown") + .and_then(|v| v.as_u64()) + == Some(1); self.steering_supported = result .pointer("/_meta/steering/supported") .and_then(|v| v.as_bool()) @@ -1069,9 +1121,12 @@ impl AcpClient { const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); let line = serde_json::to_string(value)?; tokio::time::timeout(WRITE_TIMEOUT, async { - self.stdin.write_all(line.as_bytes()).await?; - self.stdin.write_all(b"\n").await?; - self.stdin.flush().await?; + let stdin = self.stdin.as_mut().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "agent connection closed") + })?; + stdin.write_all(line.as_bytes()).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await?; Ok::<(), std::io::Error>(()) }) .await @@ -5028,3 +5083,7 @@ mod tests { ); } } + +#[cfg(all(test, unix))] +#[path = "acp_shutdown_tests.rs"] +mod shutdown_tests; diff --git a/crates/buzz-acp/src/acp_shutdown_tests.rs b/crates/buzz-acp/src/acp_shutdown_tests.rs new file mode 100644 index 00000000000..b5a8b39e839 --- /dev/null +++ b/crates/buzz-acp/src/acp_shutdown_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use std::time::Duration; + +async fn shell(script: &str) -> AcpClient { + AcpClient::spawn("/bin/sh", &["-c".into(), script.into()], &[], false) + .await + .unwrap() +} + +#[tokio::test] +async fn shutdown_closes_stdin_drains_full_stdout_and_reaps() { + let mut client = shell("cat >/dev/null; head -c 524288 /dev/zero; exit 0").await; + client.shutdown().await; + assert!(client.child.try_wait().unwrap().unwrap().success()); + assert!(client.write_ndjson(&serde_json::json!({})).await.is_err()); + // Repeated shutdown cannot signal a recycled PID. + client.shutdown().await; +} + +#[tokio::test] +async fn shutdown_escalates_hung_owner_but_preserves_peer() { + let mut selected = shell("exec sleep 600").await; + let mut peer = shell("exec sleep 600").await; + selected + .shutdown_with_grace(Duration::from_millis(30)) + .await; + assert!(!selected.child.try_wait().unwrap().unwrap().success()); + assert!(peer.child.try_wait().unwrap().is_none()); + peer.shutdown_with_grace(Duration::from_millis(30)).await; +} + +#[tokio::test] +async fn supported_result_requires_successful_root_exit_and_is_idempotent() { + for (exit, expected) in [(0, true), (7, false)] { + let mut client = shell(&format!(r#" + read init + echo '{{"jsonrpc":"2.0","id":0,"result":{{"protocolVersion":1,"_meta":{{"buzzOwnedWorkShutdown":1}}}}}}' + read stop + echo '{{"jsonrpc":"2.0","id":1,"result":{{"v":1,"ownedWorkStopped":true}}}}' + exit {exit} + "#)).await; + client.initialize().await.unwrap(); + client.shutdown().await; + assert_eq!(client.teardown_confirmed, expected); + client.shutdown().await; + assert_eq!(client.teardown_confirmed, expected); + } + let mut unsupported = shell("cat >/dev/null; exit 0").await; + unsupported.shutdown().await; + assert!(!unsupported.teardown_confirmed); +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 2de474ac1e2..f0731ab5384 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -12,6 +12,7 @@ mod prompt_project; mod queue; mod relay; mod setup_mode; +mod shutdown; mod usage; pub use usage::TurnUsage; @@ -84,29 +85,8 @@ fn current_working_directory() -> Result { Ok(cwd.to_string_lossy().into_owned()) } -/// Publish a kind:20001 presence update event via the WebSocket connection. -/// -/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence -/// updates must be routed through the WS path. -/// -/// Content is a bare status string (`"online"`, `"away"`, `"offline"`) matching -/// the desktop client's format. The relay stores this in Redis and synthesizes -/// it back on presence queries. -async fn publish_presence( - publisher: &relay::RelayEventPublisher, - keys: &nostr::Keys, - status: &str, -) -> Result<(), relay::RelayError> { - use buzz_core::kind::KIND_PRESENCE_UPDATE; - use nostr::{EventBuilder, Kind}; - - let event = EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status) - .tags([]) - .sign_with_keys(keys) - .map_err(|e| relay::RelayError::Http(format!("presence sign error: {e}")))?; - publisher.publish_event(event).await?; - Ok(()) -} +mod run_presence; +use run_presence::PresencePublisher; fn emit_runtime_lifecycle( observer: Option<&observer::ObserverHandle>, @@ -2071,7 +2051,7 @@ impl RespawnGuard { /// Send the result and disarm the guard. Uses `try_send` (sync) so there /// is no await boundary between marking `sent` and actually enqueueing — /// cancellation cannot slip between the two. - fn send(mut self, result: Result<(AcpClient, u32, String)>) { + async fn send(mut self, result: Result<(AcpClient, u32, String)>) { // Invariant: try_send succeeds because the channel capacity equals the // slot count, and respawn_in_flight guarantees at most one outstanding // result per slot. If this ever fails, the channel sizing or the @@ -2086,6 +2066,9 @@ impl RespawnGuard { agent = self.index, "respawn result channel full or closed: {e}" ); + if let Ok((mut acp, _, _)) = e.into_inner().result { + acp.shutdown().await; + } // Drop will fire and send a failure result as fallback. } } @@ -2451,11 +2434,37 @@ async fn tokio_main() -> Result<()> { ); } + // Install handlers synchronously before the first child can exist. + let (shutdown_tx, mut shutdown_rx) = shutdown::install()?; + let run_presence = PresencePublisher::from_env().map_err(anyhow::Error::msg)?; + let cwd = current_working_directory()?; + let mut pool = if config.lazy_pool { AgentPool::from_slots((0..config.agents).map(|_| None).collect()) } else { - initialize_agent_pool(&PoolStartup::from_config(&config, observer.clone()), None).await? + initialize_agent_pool( + &PoolStartup::from_config(&config, observer.clone()), + Some(shutdown_rx.clone()), + ) + .await? }; + // Startup awaits own the pool: cancellation/error must drain, not Drop it. + macro_rules! startup_step { + ($future:expr) => {{ + let result = tokio::select! { + biased; + _ = shutdown::cancelled(&mut shutdown_rx) => Err(anyhow::anyhow!("startup cancelled")), + result = $future => result.map_err(anyhow::Error::from), + }; + match result { + Ok(value) => value, + Err(error) => { + shutdown_agent_pool(&mut pool).await; + return Err(error); + } + } + }}; + } let mut pool_ready = !config.lazy_pool; let mut pool_lifecycle: PoolLifecycle = PoolLifecycle::listening(); @@ -2477,29 +2486,33 @@ async fn tokio_main() -> Result<()> { .filter(|s| !s.is_empty()) .and_then(|s| buzz_sdk::nip_oa::parse_auth_tag(&s).ok()); - let mut relay = - HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag) - .await - .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?; + let mut relay = startup_step!(HarnessRelay::connect( + &config.relay_url, + &config.keys, + &pubkey_hex, + relay_auth_tag + )); // Tell the relay background task the watermark so it can use // `since = watermark - 5s` on the first REQ instead of `since=now`. // Best-effort: a failure here is non-fatal (we just lose the startup window // protection, which is the same as the pre-fix behaviour). - if let Err(e) = relay.set_startup_watermark(startup_watermark).await { + if let Err(e) = startup_step!(async { + Ok::<_, anyhow::Error>(relay.set_startup_watermark(startup_watermark).await) + }) { tracing::warn!("failed to set startup watermark: {e}"); } tracing::info!("connected to relay at {}", config.relay_url); let relay_rest_client = relay.rest_client(); - let mut author_gate_ctx = - InboundAuthorGate::connect(&relay_rest_client, &pubkey_hex, "startup").await; + let mut author_gate_ctx = startup_step!(async { + Ok::<_, anyhow::Error>( + InboundAuthorGate::connect(&relay_rest_client, &pubkey_hex, "startup").await, + ) + }); - relay - .subscribe_membership_notifications() - .await - .map_err(|e| anyhow::anyhow!("membership notification subscribe error: {e}"))?; + startup_step!(relay.subscribe_membership_notifications()); tracing::info!("subscribed to membership notifications"); let presence_publisher = relay.event_publisher(); @@ -2550,10 +2563,7 @@ async fn tokio_main() -> Result<()> { owner_pubkey_hex, owner_pubkey, )); - relay - .subscribe_observer_controls() - .await - .map_err(|e| anyhow::anyhow!("observer control subscribe error: {e}"))?; + startup_step!(relay.subscribe_observer_controls()); relay_observer_control_rx = relay.take_observer_control_rx(); tracing::info!("relay observer enabled"); } @@ -2569,10 +2579,7 @@ async fn tokio_main() -> Result<()> { } } - let channel_info_map = relay - .discover_channels() - .await - .map_err(|e| anyhow::anyhow!("channel discovery error: {e}"))?; + let channel_info_map = startup_step!(relay.discover_channels()); tracing::info!("discovered {} channel(s)", channel_info_map.len()); let channel_ids: Vec = channel_info_map.keys().copied().collect(); @@ -2610,7 +2617,7 @@ async fn tokio_main() -> Result<()> { } SubscribeMode::Config => { // load_rules() already warns if the config file has zero rules. - config::load_rules(&config.config_path)? + startup_step!(async { config::load_rules(&config.config_path) }) } }; @@ -2620,7 +2627,9 @@ async fn tokio_main() -> Result<()> { } let mut subscribed_channel_ids = HashSet::with_capacity(channel_filters.len()); for (channel_id, filter) in &channel_filters { - if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await { + if let Err(e) = startup_step!(async { + Ok::<_, anyhow::Error>(relay.subscribe_channel(*channel_id, filter.clone()).await) + }) { tracing::warn!("failed to subscribe to channel {channel_id}: {e}"); } else { subscribed_channel_ids.insert(*channel_id); @@ -2650,7 +2659,13 @@ async fn tokio_main() -> Result<()> { // connected. Publishing after channel subscriptions gives desktop callers // a durable readiness boundary before they send a startup mention. if config.presence_enabled { - match publish_presence(&presence_publisher, &presence_keys, "online").await { + match startup_step!(async { + Ok::<_, anyhow::Error>( + run_presence + .publish(&presence_publisher, &presence_keys, "online") + .await, + ) + }) { Ok(_) => tracing::info!("presence set to online"), Err(e) => tracing::warn!("failed to set initial presence: {e}"), } @@ -2668,7 +2683,6 @@ async fn tokio_main() -> Result<()> { } let base_prompt_content = config.base_prompt_content.take(); - let cwd = current_working_directory()?; let ctx = Arc::new(PromptContext { mcp_servers: build_mcp_servers(&config), initial_message: config.initial_message.clone(), @@ -2804,26 +2818,6 @@ async fn tokio_main() -> Result<()> { // `IN_FLIGHT_DEADLINE_SECS` expires. let (steer_ack_tx, mut steer_ack_rx) = mpsc::unbounded_channel::(); - // ── Step 7: Shutdown signal ─────────────────────────────────────────────── - let (shutdown_tx, mut shutdown_rx) = watch::channel(()); - - let tx = shutdown_tx.clone(); - tokio::spawn(async move { - tokio::signal::ctrl_c().await.ok(); - let _ = tx.send(()); - }); - - #[cfg(unix)] - { - let tx = shutdown_tx.clone(); - tokio::spawn(async move { - use tokio::signal::unix::{signal, SignalKind}; - let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler"); - sigterm.recv().await; - let _ = tx.send(()); - }); - } - // Track the newest membership notification timestamp per channel. // On reconnect the relay replays events newest-first, so the first event // per channel is authoritative. Any later event with ts < newest is stale. @@ -2941,7 +2935,7 @@ async fn tokio_main() -> Result<()> { let guard = RespawnGuard::new(idx, respawn_tx.clone()); respawn_tasks.spawn(async move { let result = spawn_and_init(&cmd, &args, &env, has_codex, idx, observer).await; - guard.send(result); + guard.send(result).await; }); } @@ -3246,7 +3240,7 @@ async fn tokio_main() -> Result<()> { sender = %buzz_event.event.pubkey.to_hex(), "shutdown command from owner — exiting gracefully" ); - let _ = shutdown_tx.send(()); + let _ = shutdown_tx.send(true); continue; } } @@ -3419,7 +3413,7 @@ async fn tokio_main() -> Result<()> { inactivity_seconds = config.exit_after_inactivity_secs, "inactivity bound reached — exiting gracefully" ); - let _ = shutdown_tx.send(()); + let _ = shutdown_tx.send(true); } None } @@ -3509,8 +3503,9 @@ async fn tokio_main() -> Result<()> { } let pp = presence_publisher.clone(); let pk = presence_keys.clone(); + let pulse = run_presence.clone(); presence_task = Some(tokio::spawn(async move { - if let Err(e) = publish_presence(&pp, &pk, "online").await { + if let Err(e) = pulse.publish(&pp, &pk, "online").await { tracing::warn!("presence heartbeat failed: {e}"); } })); @@ -3539,7 +3534,7 @@ async fn tokio_main() -> Result<()> { } None } - _ = shutdown_rx.changed() => { + _ = shutdown::cancelled(&mut shutdown_rx) => { tracing::info!("shutting down"); break; } @@ -3823,7 +3818,7 @@ async fn tokio_main() -> Result<()> { // signal handlers (result channel closed, LoopAction::Exit) cancel the wake // just as promptly. Timeout is a backstop for a slot stuck outside the // select (e.g. in spawn); only then do we fall back to aborting. - let _ = shutdown_tx.send(()); + let _ = shutdown_tx.send(true); let wake_drain = tokio::time::timeout(Duration::from_secs(30), async { while wake_tasks.join_next().await.is_some() {} }) @@ -3838,6 +3833,14 @@ async fn tokio_main() -> Result<()> { } } + // Stop work before draining. Waiting alone leaves long-running tools alive + // until the grace expires, then aborts their AcpClient owner mid-cleanup. + // Includes heartbeat turns (which have no channel_id). + for meta in pool.task_map_mut().values_mut() { + if let Some(tx) = meta.control_tx.take() { + let _ = tx.send(ControlSignal::Cancel); + } + } tracing::info!("shutdown: waiting for in-flight prompts"); // 30 s is generous for in-flight prompts to be cancelled; using // max_turn_duration here would cause Ctrl+C to hang for up to an hour. @@ -3892,11 +3895,16 @@ async fn tokio_main() -> Result<()> { } drop(pool); - // Abort any in-flight respawn tasks. They may be sleeping in backoff or - // running spawn_and_init — either way, we don't want them spawning new - // children after the main loop has exited. RespawnGuard::Drop sends a - // failure result for aborted tasks, so respawn_in_flight is cleared. - respawn_tasks.shutdown().await; + // The same sticky stop signal interrupts respawn backoff/initialization. + // Join owners so they can drain clients; abort is only an uncertain fallback. + if tokio::time::timeout(Duration::from_secs(30), async { + while respawn_tasks.join_next().await.is_some() {} + }) + .await + .is_err() + { + respawn_tasks.shutdown().await; + } // Drain any respawn results that completed before the abort. Explicitly // shut down returned agents instead of relying on AcpClient::Drop. @@ -3916,7 +3924,7 @@ async fn tokio_main() -> Result<()> { if config.presence_enabled { match tokio::time::timeout( Duration::from_secs(2), - publish_presence(&presence_publisher, &presence_keys, "offline"), + run_presence.publish(&presence_publisher, &presence_keys, "offline"), ) .await { @@ -3934,6 +3942,7 @@ async fn tokio_main() -> Result<()> { // for the background task to finish, rather than aborting immediately (#40). relay.shutdown().await; + shutdown::write_receipt(&config.keys, &config.relay_url, &runtime_start_nonce)?; tracing::info!("buzz-acp stopped"); Ok(()) } @@ -4768,10 +4777,10 @@ fn recover_panicked_agent( let guard = RespawnGuard::new(i, respawn_tx.clone()); respawn_tasks.spawn(async move { if !delay.is_zero() { - tokio::time::sleep(delay).await; + shutdown::backoff(delay).await; } let result = spawn_and_init(&cmd, &args, &env, has_codex, i, observer).await; - guard.send(result); + guard.send(result).await; }); } @@ -4835,6 +4844,7 @@ fn dispatch_heartbeat( let turn_id = Uuid::new_v4().to_string(); let task_turn_id = turn_id.clone(); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); let abort_handle = pool.join_set.spawn(async move { pool::run_prompt_task( agent, @@ -4842,7 +4852,7 @@ fn dispatch_heartbeat( Some(prompt_text), ctx_clone, result_tx, - None, + Some(control_rx), task_turn_id, ) .await; @@ -4855,7 +4865,7 @@ fn dispatch_heartbeat( channel_id: None, turn_id, recoverable_batch: None, - control_tx: None, + control_tx: Some(control_tx), steer_tx: None, successful_steer_deliveries: HashSet::new(), }, @@ -4973,6 +4983,10 @@ fn spawn_respawn_task( let delay = match slot.record_crash() { CrashVerdict::CircuitOpen => { tracing::error!(agent = index, "circuit open — not respawning"); + respawn_tasks.spawn(async move { + let mut agent = old_agent; + agent.acp.shutdown().await; + }); return false; } CrashVerdict::HalfOpenProbe => { @@ -5000,11 +5014,11 @@ fn spawn_respawn_task( drop(agent); if !delay.is_zero() { - tokio::time::sleep(delay).await; + shutdown::backoff(delay).await; } let result = spawn_and_init(&cmd, &args, &env, has_codex, index, observer).await; - guard.send(result); + guard.send(result).await; }); true @@ -5069,12 +5083,16 @@ impl PoolStartup { async fn initialize_agent_pool( startup: &PoolStartup, - mut shutdown: Option>, + mut shutdown: Option>, ) -> Result { // One agent failing to start must not kill the whole pool. // Attempt each spawn under a 60-second timeout; a partial pool is valid. let mut agent_slots: Vec> = Vec::with_capacity(startup.agents as usize); for i in 0..startup.agents as usize { + if shutdown.as_ref().is_some_and(|rx| *rx.borrow()) { + shutdown_agent_slots(&mut agent_slots).await; + return Err(anyhow::anyhow!("pool initialization cancelled by shutdown")); + } let spawn_result = AcpClient::spawn( &startup.command, &startup.args, @@ -5089,7 +5107,7 @@ async fn initialize_agent_pool( let initialize_result = match shutdown.as_mut() { Some(shutdown) => tokio::select! { biased; - _ = shutdown.changed() => { + _ = shutdown::cancelled(shutdown) => { acp.shutdown().await; shutdown_agent_slots(&mut agent_slots).await; return Err(anyhow::anyhow!("pool initialization cancelled by shutdown")); @@ -5186,12 +5204,21 @@ async fn spawn_and_init( agent_index: usize, observer: Option, ) -> Result<(AcpClient, u32, String)> { + let mut shutdown = shutdown::receiver(); + if shutdown.as_ref().is_some_and(|rx| *rx.borrow()) { + return Err(anyhow::anyhow!("respawn cancelled")); + } let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config) .await .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; acp.set_observer(observer, agent_index); - match acp.initialize().await { + let initialized = tokio::select! { + biased; + _ = shutdown::cancelled_optional(&mut shutdown) => Err(acp::AcpError::Protocol("respawn cancelled".into())), + result = acp.initialize() => result, + }; + match initialized { Ok(init_result) => { tracing::info!("agent initialized: {init_result}"); let protocol_version = init_result["protocolVersion"].as_u64().unwrap_or(1) as u32; diff --git a/crates/buzz-acp/src/run_presence.rs b/crates/buzz-acp/src/run_presence.rs new file mode 100644 index 00000000000..f80b84ccf28 --- /dev/null +++ b/crates/buzz-acp/src/run_presence.rs @@ -0,0 +1,84 @@ +//! One presence generation per harness process, independent of parallel sessions. +use crate::relay::{RelayError, RelayEventPublisher}; +use buzz_core::run_presence::{self, Location}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; + +#[derive(Clone)] +pub(crate) struct PresencePublisher { + run: String, + seq: Arc, + location: Option, +} +impl PresencePublisher { + pub(crate) fn from_env() -> Result { + let location = std::env::var("BUZZ_ACP_HOST_PUBKEY") + .ok() + .zip(std::env::var("BUZZ_ACP_HOST_LABEL").ok()) + .map(|(host, label)| Location { host, label }) + .filter(|l| l.validate().is_ok()); + let launcher = match std::env::var("BUZZ_MANAGED_AGENT_START_NONCE") { + Ok(value) => Some(value), + Err(std::env::VarError::NotPresent) => None, + Err(_) => return Err("invalid launcher run generation".into()), + }; + Ok(Self { + run: run_generation(launcher.as_deref())?, + seq: Arc::new(AtomicU64::new(0)), + location, + }) + } + pub(crate) async fn publish( + &self, + publisher: &RelayEventPublisher, + keys: &nostr::Keys, + status: &str, + ) -> Result<(), RelayError> { + let event = run_presence::pulse( + keys, + &self.run, + self.seq.fetch_add(1, Ordering::SeqCst), + status, + self.location.as_ref(), + None, + nostr::Timestamp::now().as_secs(), + ) + .map_err(RelayError::Http)?; + publisher.publish_event(event).await?; + Ok(()) + } +} + +// The launcher generation is the public run ID, not a second random identity. +// Standalone harnesses still generate their own run. A malformed launcher ID +// must fail startup rather than publish a run the controller cannot fence. +fn run_generation(launcher: Option<&str>) -> Result { + match launcher { + Some(id) + if id.len() == 32 + && id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => + { + Ok(id.to_owned()) + } + Some(_) => Err("invalid launcher run generation".into()), + None => Ok(uuid::Uuid::new_v4().simple().to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn presence_uses_exact_launcher_generation() { + let id = "ab".repeat(16); + assert_eq!(run_generation(Some(&id)).unwrap(), id); + for bad in ["", "test-generation", &"AB".repeat(16), &"aa".repeat(32)] { + assert!(run_generation(Some(bad)).is_err()); + } + assert_ne!(run_generation(None).unwrap(), run_generation(None).unwrap()); + } +} diff --git a/crates/buzz-acp/src/shutdown.rs b/crates/buzz-acp/src/shutdown.rs new file mode 100644 index 00000000000..511f6a1f54b --- /dev/null +++ b/crates/buzz-acp/src/shutdown.rs @@ -0,0 +1,99 @@ +//! One process-wide stop signal, installed before owning children. Respawn and +//! eager/lazy startup share this authority; no second lifecycle/run identity. +use std::{sync::OnceLock, time::Duration}; +use tokio::sync::watch; +static SHUTDOWN: OnceLock> = OnceLock::new(); + +pub(crate) fn install() -> std::io::Result<(watch::Sender, watch::Receiver)> { + let (tx, rx) = watch::channel(false); + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate())?; + let mut interrupt = signal(SignalKind::interrupt())?; + let tx = tx.clone(); + tokio::spawn(async move { + tokio::select! { _ = term.recv() => {}, _ = interrupt.recv() => {} } + let _ = tx.send(true); + }); + } + #[cfg(not(unix))] + { + let tx = tx.clone(); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + let _ = tx.send(true); + } + }); + } + let _ = SHUTDOWN.set(tx.clone()); + Ok((tx, rx)) +} +pub(crate) fn receiver() -> Option> { + SHUTDOWN.get().map(watch::Sender::subscribe) +} +pub(crate) async fn cancelled(rx: &mut watch::Receiver) { + while !*rx.borrow_and_update() { + if rx.changed().await.is_err() { + return; + } + } +} +pub(crate) async fn cancelled_optional(rx: &mut Option>) { + match rx { + Some(rx) => cancelled(rx).await, + None => std::future::pending().await, + } +} +pub(crate) async fn backoff(delay: Duration) { + let mut rx = receiver(); + tokio::select! { + _ = cancelled_optional(&mut rx) => {}, + _ = tokio::time::sleep(delay) => {}, + } +} + +// One harness invocation owns every ACP child it ever starts, including failed +// init/respawn and dropped tasks. Only explicit supported completion removes it. +static UNCONFIRMED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +pub(crate) fn child_spawned() { + UNCONFIRMED.fetch_add(1, std::sync::atomic::Ordering::AcqRel); +} +pub(crate) fn child_confirmed() { + UNCONFIRMED.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); +} +pub(crate) fn write_receipt(keys: &nostr::Keys, relay: &str, run: &str) -> anyhow::Result<()> { + use std::io::Write; + if UNCONFIRMED.load(std::sync::atomic::Ordering::Acquire) != 0 { + return Ok(()); + } + let Some(path) = std::env::var_os("BUZZ_STOP_RECEIPT_PATH") else { + return Ok(()); + }; + let receipt = buzz_core::owned_stop::sign(keys, relay, run).map_err(anyhow::Error::msg)?; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(serde_json::to_string(&receipt)?.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn cancellation_is_sticky_for_late_subscribers() { + let (tx, _rx) = watch::channel(false); + tx.send(true).unwrap(); + let mut late = tx.subscribe(); + tokio::time::timeout(Duration::from_millis(20), cancelled(&mut late)) + .await + .unwrap(); + } +} diff --git a/crates/buzz-acp/tests/startup_shutdown.rs b/crates/buzz-acp/tests/startup_shutdown.rs new file mode 100644 index 00000000000..a62958ad37e --- /dev/null +++ b/crates/buzz-acp/tests/startup_shutdown.rs @@ -0,0 +1,155 @@ +//! Real harness startup cancellation: no relay/provider or user configuration. +#![cfg(unix)] +use std::{ + fs, + path::Path, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; +struct TestDir(std::path::PathBuf); +impl TestDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("buzz-stop-startup-{}", uuid::Uuid::new_v4())); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn path(&self) -> &Path { + &self.0 + } +} +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +struct Owner(Child); +impl Drop for Owner { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn wait_file(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(10); + while !path.exists() { + assert!(Instant::now() < deadline, "missing {}", path.display()); + std::thread::sleep(Duration::from_millis(10)); + } +} +fn gone(pid: &str) -> bool { + !Command::new("/bin/kill") + .args(["-0", pid]) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() +} +fn exercise(partial: bool, relay_error: bool) { + let dir = TestDir::new(); + let script = dir.path().join("agent.sh"); + fs::write( + &script, + r#" + if mkdir "$HOME/first" 2>/dev/null; then slot=first; else slot=second; fi + echo $$ > "$HOME/$slot.pid" + if [ "$MODE" = relay ] || { [ "$MODE" = partial ] && [ "$slot" = first ]; }; then + read init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1}}' + fi + cat >/dev/null + echo done > "$HOME/$slot.done" + "#, + ) + .unwrap(); + let log = fs::File::create(dir.path().join("harness.log")).unwrap(); + let mut owner = Owner( + Command::new(env!("CARGO_BIN_EXE_buzz-acp")) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", dir.path()) + .env("XDG_CONFIG_HOME", dir.path()) + .env("TMPDIR", dir.path()) + .env( + "MODE", + if relay_error { + "relay" + } else if partial { + "partial" + } else { + "early" + }, + ) + .env("BUZZ_PRIVATE_KEY", "1".repeat(64)) + .args([ + "--relay-url", + if relay_error { + "not-a-url" + } else { + "ws://127.0.0.1:1" + }, + "--agent-command", + "/bin/sh", + "--agent-args", + script.to_str().unwrap(), + "--agents", + if partial { "2" } else { "1" }, + "--no-memory", + "--no-presence", + ]) + .current_dir(dir.path()) + .stdin(Stdio::null()) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .unwrap(), + ); + wait_file( + &dir.path() + .join(if partial { "second.pid" } else { "first.pid" }), + ); + if !relay_error { + assert!(Command::new("/bin/kill") + .args(["-TERM", &owner.0.id().to_string()]) + .status() + .unwrap() + .success()); + } + let deadline = Instant::now() + Duration::from_secs(10); + while owner.0.try_wait().unwrap().is_none() { + assert!( + Instant::now() < deadline, + "{}", + fs::read_to_string(dir.path().join("harness.log")).unwrap() + ); + std::thread::sleep(Duration::from_millis(10)); + } + for slot in if partial { + vec!["first", "second"] + } else { + vec!["first"] + } { + assert_eq!( + fs::read_to_string(dir.path().join(format!("{slot}.done"))) + .unwrap() + .trim(), + "done" + ); + assert!(gone( + fs::read_to_string(dir.path().join(format!("{slot}.pid"))) + .unwrap() + .trim() + )); + } +} +#[test] +fn sigterm_during_eager_initialize_drains_child() { + exercise(false, false); +} +#[test] +fn sigterm_during_partial_pool_initialize_drains_all_slots() { + exercise(true, false); +} +#[test] +fn relay_startup_error_drains_initialized_pool() { + exercise(false, true); +} diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index b094a0f9fd7..c84eecd4fd7 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -6,9 +6,11 @@ pub mod catalog; pub mod config; mod handoff; mod hints; +mod lifecycle; mod llm; mod mcp; pub mod model_capabilities; +mod owned_mcp; mod permission; pub mod types; mod wire; @@ -73,6 +75,8 @@ struct App { /// OAuth authentication and non-auth errors use the configured model for that /// response and retry on the next session. models_cache: tokio::sync::OnceCell>, + tasks: Mutex>, + unfinished_tasks: std::sync::atomic::AtomicUsize, } struct Session { @@ -205,6 +209,8 @@ async fn async_main() { negotiated_version: AtomicU32::new(PROTOCOL_VERSION), permissions, models_cache: tokio::sync::OnceCell::new(), + tasks: Mutex::new(tokio::task::JoinSet::new()), + unfinished_tasks: Default::default(), }); let (wire_tx, wire_rx) = mpsc::channel::(64); let mut writer = tokio::spawn(wire::writer_task(wire_rx)); @@ -214,22 +220,41 @@ async fn async_main() { // stop reading and cancel every session rather than leave the process // reading input while outstanding permission asks wait out their full // deadline for a response that can never arrive. - tokio::select! { + let shutdown_id = tokio::select! { r = read_loop( BufReader::new(tokio::io::stdin()), app.clone(), wire_tx, max_line, ) => { - if let Err(e) = r { - tracing::error!("io: reader: {e}"); - } - cancel_all_sessions(&app).await; - let _ = writer.await; + r.unwrap_or_else(|e| { tracing::error!("io: reader: {e}"); None }) } _ = &mut writer => { tracing::error!("io: writer exited (stdout closed); shutting down connection"); - cancel_all_sessions(&app).await; + None + } + }; + // No more requests can be admitted. Closing the output queue also releases + // tasks blocked on a full pipe; a peer which stopped reading must not hold + // workload teardown hostage. + writer.abort(); + if !writer.is_finished() { + let _ = writer.await; + } + let confirmed = lifecycle::shutdown(&app).await; + if let Some(id) = shutdown_id { + // The ordinary writer is joined: no interleaving with its buffered + // notifications, and no further request can be admitted. + use tokio::io::AsyncWriteExt; + let response = wire::ok(id, json!({"ownedWorkStopped": confirmed, "v": 1})); + if let Ok(mut line) = serde_json::to_vec(&response) { + line.push(b'\n'); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + let mut out = tokio::io::stdout(); + out.write_all(&line).await?; + out.flush().await + }) + .await; } } } @@ -248,13 +273,20 @@ async fn read_loop( app: Arc, wire_tx: WireSender, max_line: usize, -) -> std::io::Result<()> { +) -> std::io::Result> { while let Some(line) = wire::read_bounded_line(&mut stdin, max_line).await? { if line.trim().is_empty() { continue; } match serde_json::from_str::(&line) { - Ok(msg) => dispatch(&app, msg, &wire_tx).await, + Ok(msg) => { + if let Inbound::Request { id, method, .. } = classify(&msg) { + if method == "_buzz/shutdown_v1" { + return Ok(Some(id)); + } + } + dispatch(&app, msg, &wire_tx).await; + } Err(e) => { wire::send( &wire_tx, @@ -264,7 +296,7 @@ async fn read_loop( } } } - Ok(()) + Ok(None) } async fn dispatch(app: &Arc, msg: Value, wire_tx: &WireSender) { @@ -295,9 +327,16 @@ async fn handle_request( "session/new" => { let app = app.clone(); let wire_tx = wire_tx.clone(); - tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await }); + let task_app = app.clone(); + let mut tasks = app.tasks.lock().await; + while tasks.try_join_next().is_some() {} + app.unfinished_tasks.fetch_add(1, Ordering::AcqRel); + tasks.spawn(async move { + session_new(&task_app, id, params, &wire_tx).await; + task_app.unfinished_tasks.fetch_sub(1, Ordering::AcqRel); + }); } - "session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()), + "session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()).await, "session/set_model" => { set_model_session(app, id, params, wire_tx).await; } @@ -362,6 +401,7 @@ async fn initialize(app: &Arc, id: Value, params: Value, wire_tx: &WireSend "mcpCapabilities": { "http": false, "sse": false }, }, "agentInfo": { "name": "buzz-agent", "version": env!("CARGO_PKG_VERSION") }, + "_meta": {"buzzOwnedWorkShutdown": 1}, }), ), ) @@ -526,12 +566,17 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen }; let session_id = match session_token() { Ok(t) => format!("ses_{t}"), - Err(e) => return reject(wire_tx, id, -32000, &e).await, + Err(e) => { + mcp.shutdown().await; + return reject(wire_tx, id, -32000, &e).await; + } }; let (cancel_tx, _) = watch::channel(false); let mut sessions = app.sessions.lock().await; // Re-check cap (another session may have been created while we spawned MCP). if sessions.len() >= app.cfg.max_sessions { + drop(sessions); + mcp.shutdown().await; return reject( wire_tx, id, @@ -726,8 +771,15 @@ async fn steer_session(app: &Arc, id: Value, params: Value, wire_tx: &WireS .await; } -fn spawn_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { - tokio::spawn(async move { run_prompt(app, id, params, wire_tx).await }); +async fn spawn_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { + let task_app = app.clone(); + let mut tasks = app.tasks.lock().await; + while tasks.try_join_next().is_some() {} + app.unfinished_tasks.fetch_add(1, Ordering::AcqRel); + tasks.spawn(async move { + run_prompt(task_app.clone(), id, params, wire_tx).await; + task_app.unfinished_tasks.fetch_sub(1, Ordering::AcqRel); + }); } async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { diff --git a/crates/buzz-agent/src/lifecycle.rs b/crates/buzz-agent/src/lifecycle.rs new file mode 100644 index 00000000000..64c4e261af9 --- /dev/null +++ b/crates/buzz-agent/src/lifecycle.rs @@ -0,0 +1,52 @@ +//! Connection owns session creation and prompt tasks, and outlives MCP cleanup. +use std::{sync::Arc, time::Duration}; + +use crate::{cancel_all_sessions, App}; + +pub(crate) async fn shutdown(app: &Arc) -> bool { + let mut tasks = app.tasks.lock().await; + let drain = async { + // session/new can complete during teardown. Repeat cancellation so a + // prompt which had not acquired its session at EOF cannot escape it. + let mut tick = tokio::time::interval(Duration::from_millis(20)); + loop { + cancel_all_sessions(app).await; + if tasks.is_empty() { + break; + } + tokio::select! { + result = tasks.join_next() => { + if let Some(Err(error)) = result { + tracing::warn!(%error, "connection task failed during shutdown"); + } + } + _ = tick.tick() => {} + } + } + }; + if tokio::time::timeout(Duration::from_secs(10), drain) + .await + .is_err() + { + tracing::warn!("connection task drain timed out; teardown is unconfirmed"); + tasks.shutdown().await; + } + // No task can still borrow a client or create a new session. Await rmcp's + // transport close. The client requests explicit supported work completion + // before closing stdin and inspecting its retained MCP child exit. + let sessions = std::mem::take(&mut *app.sessions.lock().await); + let mut closing = tokio::task::JoinSet::new(); + for (_, session) in sessions { + closing.spawn(async move { session.mcp.shutdown().await }); + } + let mut joined = true; + while let Some(result) = closing.join_next().await { + joined &= result.is_ok(); + } + joined + && app + .unfinished_tasks + .load(std::sync::atomic::Ordering::Acquire) + == 0 + && crate::owned_mcp::all_confirmed() +} diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 42c9cc48780..aa63ea84ab9 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -2,10 +2,9 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; +use crate::owned_mcp::{Client, OwnedTransport}; use arc_swap::ArcSwap; use rmcp::model::CallToolRequestParams; -use rmcp::service::{RoleClient, RunningService}; -use rmcp::transport::TokioChildProcess; use rmcp::ServiceError; use rmcp::ServiceExt; use serde_json::{Map, Value}; @@ -110,8 +109,6 @@ fn windows_child_passthrough_env() -> impl Iterator { .chain(crate::WINDOWS_SHELL_RESOLUTION_ENV.iter().copied()) } -type Client = RunningService; - #[derive(Clone)] struct ServerSpec { name: String, @@ -222,6 +219,16 @@ impl McpRegistry { hook_timeouts: std::sync::Mutex::new(HashMap::new()), }; + let result = reg.populate(servers, cwd).await; + if let Err(error) = result { + reg.shutdown().await; + return Err(error); + } + Ok(reg) + } + + async fn populate(&mut self, servers: &[McpServerStdio], cwd: &str) -> Result<(), AgentError> { + let reg = self; let mut seen_names = HashSet::new(); for s in servers { if !valid_name(&s.name) || s.name.contains("__") { @@ -290,7 +297,44 @@ impl McpRegistry { reg.by_qname.insert(qname, Entry { server_idx, bare }); } } - Ok(reg) + Ok(()) + } + + /// Close all transports after the connection's prompt/init tasks are joined. + /// The owned transport retains actual exit evidence separately from rmcp's + /// close result. Unsupported or incomplete child work remains unconfirmed. + pub async fn shutdown(&self) { + let mut closing = tokio::task::JoinSet::new(); + for server in &self.servers { + let server = server.clone(); + closing.spawn(async move { + let _lock = server.restart_lock.lock().await; + let old = server.client.swap(Arc::new(ClientState::Dead { + attempts: u32::MAX, + next_retry: Instant::now(), + reason: "connection closed".into(), + tools: Arc::new(Vec::new()), + })); + let Ok(state) = Arc::try_unwrap(old) else { + tracing::warn!(server = %server.name, "MCP still borrowed at shutdown; teardown unconfirmed"); + return; + }; + if let ClientState::Healthy { client, pgid, .. } = state { + match Arc::try_unwrap(client) { + Ok(client) => { + if let Err(error) = client.cancel().await { + tracing::warn!(%error, "MCP cleanup task failed"); + if let Some(pid) = pgid { + killpg(pid, &server.name, "close_failed"); + } + } + } + Err(_) => tracing::warn!(server = %server.name, "MCP client still borrowed; teardown unconfirmed"), + } + } + }); + } + while closing.join_next().await.is_some() {} } pub fn server_of(&self, qname: &str) -> Option<&str> { @@ -756,9 +800,10 @@ async fn spawn_one( configure_no_window(&mut cmd); - let transport = TokioChildProcess::new(cmd) + let transport = OwnedTransport::spawn(cmd) .map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?; let pgid = transport.id(); + let exit = transport.evidence(); struct PgidGuard { pgid: Option, @@ -776,7 +821,7 @@ async fn spawn_one( name: spec.name.clone(), }; - let client: Client = match tokio::time::timeout(timeout, ().serve(transport)).await { + let service = match tokio::time::timeout(timeout, ().serve(transport)).await { Ok(Ok(c)) => c, Ok(Err(e)) => { return Err(AgentError::Mcp(format!("init {}: {e}", spec.name))); @@ -786,12 +831,21 @@ async fn spawn_one( } }; + let mut client = Client { + service, + exit, + supported: false, + }; let tools = match tokio::time::timeout(timeout, client.peer().list_all_tools()).await { Ok(Ok(t)) => t, Ok(Err(e)) => { + let _ = client.cancel().await; + guard.pgid = None; return Err(AgentError::Mcp(format!("list_tools {}: {e}", spec.name))); } Err(_) => { + let _ = client.cancel().await; + guard.pgid = None; return Err(AgentError::Mcp(timeout_msg( "list_tools", &spec.name, @@ -799,6 +853,7 @@ async fn spawn_one( ))); } }; + client.supported = tools.iter().any(|t| t.name == "_buzz_shutdown_v1"); let names: Vec = tools.iter().map(|t| t.name.to_string()).collect(); guard.pgid = None; Ok((client, pgid, names, tools)) diff --git a/crates/buzz-agent/src/owned_mcp.rs b/crates/buzz-agent/src/owned_mcp.rs new file mode 100644 index 00000000000..68ee122b88b --- /dev/null +++ b/crates/buzz-agent/src/owned_mcp.rs @@ -0,0 +1,118 @@ +//! rmcp's default child transport hides timeout-kill/nonzero exit. Retain the +//! child ourselves so only explicit owned-work acknowledgement AND a successful +//! reaped exit can complete the connection's supported teardown evidence. +use rmcp::{ + model::CallToolRequestParams, + service::{RunningService, RxJsonRpcMessage, TxJsonRpcMessage}, + transport::{async_rw::AsyncRwTransport, Transport}, + RoleClient, ServiceError, +}; +use std::{ + future::Future, + io, + ops::Deref, + process::Stdio, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, +}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +static UNCONFIRMED: AtomicUsize = AtomicUsize::new(0); + +pub(crate) fn all_confirmed() -> bool { + UNCONFIRMED.load(Ordering::Acquire) == 0 +} + +pub(crate) struct Client { + pub(crate) supported: bool, + pub(crate) service: RunningService, + pub(crate) exit: std::sync::Arc, +} +impl Deref for Client { + type Target = RunningService; + fn deref(&self) -> &Self::Target { + &self.service + } +} +impl Client { + pub(crate) async fn cancel(self) -> Result<(), ServiceError> { + let confirmed = if self.supported { + matches!(tokio::time::timeout(Duration::from_secs(5), + self.service.peer().call_tool(CallToolRequestParams::new("_buzz_shutdown_v1")) + ).await, Ok(Ok(ref result)) if result.is_error != Some(true) + && result.content.len() == 1 + && result.content[0].as_text().is_some_and(|text| text.text == "buzz.owned-work.stopped.v1")) + } else { + false + }; + let result = self.service.cancel().await; + if confirmed && result.is_ok() && self.exit.load(Ordering::Acquire) { + UNCONFIRMED.fetch_sub(1, Ordering::AcqRel); + } + // The connection-level sticky counter also covers failed initialization, + // restart, dropped/aborted tasks, unsupported servers and forced exits. + result + .map(|_| ()) + .map_err(|_| ServiceError::TransportClosed) + } +} + +pub(crate) struct OwnedTransport { + child: Child, + io: AsyncRwTransport, + exit: std::sync::Arc, +} +impl OwnedTransport { + pub(crate) fn spawn(mut cmd: Command) -> io::Result { + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + UNCONFIRMED.fetch_add(1, Ordering::AcqRel); + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("missing MCP stdout"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::other("missing MCP stdin"))?; + Ok(Self { + child, + io: AsyncRwTransport::new(stdout, stdin), + exit: Default::default(), + }) + } + pub(crate) fn id(&self) -> Option { + self.child.id() + } + pub(crate) fn evidence(&self) -> std::sync::Arc { + self.exit.clone() + } +} +impl Transport for OwnedTransport { + type Error = io::Error; + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static { + self.io.send(item) + } + fn receive(&mut self) -> impl Future>> + Send { + self.io.receive() + } + async fn close(&mut self) -> io::Result<()> { + self.io.close().await?; + match tokio::time::timeout(Duration::from_secs(3), self.child.wait()).await { + Ok(Ok(status)) if status.success() => { + self.exit.store(true, Ordering::Release); + Ok(()) + } + _ => { + let _ = self.child.start_kill(); + let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; + Err(io::Error::other("MCP child exit unconfirmed")) + } + } + } +} diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 8d96779bbac..306a5d3c969 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -3,6 +3,8 @@ //! Reads JSON-RPC line frames on stdin and replies on stdout. Driven by //! environment variables so tests can simulate misbehavior: //! +//! FAKE_MCP_INIT_DELAY_MS=N — delay initialize response +//! FAKE_MCP_EOF_FILE=path — write after a short EOF cleanup delay //! FAKE_MCP_HANG_INIT=1 — never reply to `initialize` (init timeout) //! FAKE_MCP_HANG_TOOLS=1 — never reply to `tools/list` (list timeout) //! FAKE_MCP_TOOL_COUNT=N — return N tools (default: 1) @@ -236,6 +238,10 @@ fn main() { if hang_init { hang_forever(); } + std::thread::sleep(std::time::Duration::from_millis(env_u64( + "FAKE_MCP_INIT_DELAY_MS", + 0, + ))); write_response( id, json!({ @@ -276,6 +282,13 @@ fn main() { .and_then(|p| p.get("name")) .and_then(Value::as_str) .unwrap_or(""); + if called_name == "_buzz_shutdown_v1" { + write_response( + id, + json!({"content":[{"type":"text","text":"buzz.owned-work.stopped.v1"}],"isError":false}), + ); + continue; + } // Append every invoked tool name so a test can prove a call // reached the server exactly once (or never). This fires for // ALL tools/call, including `_Stop`/`_PostCompact` hooks, so a @@ -374,4 +387,14 @@ fn main() { } } } + if env_flag("FAKE_MCP_HANG_EXIT") { + hang_forever(); + } + if env_flag("FAKE_MCP_FAIL_EXIT") { + std::process::exit(7); + } + if let Ok(path) = std::env::var("FAKE_MCP_EOF_FILE") { + std::thread::sleep(std::time::Duration::from_millis(150)); + std::fs::write(path, "cleanup complete").unwrap(); + } } diff --git a/crates/buzz-agent/tests/common/mod.rs b/crates/buzz-agent/tests/common/mod.rs index 02bdc3bc0ef..a83a48d5dc7 100644 --- a/crates/buzz-agent/tests/common/mod.rs +++ b/crates/buzz-agent/tests/common/mod.rs @@ -224,8 +224,29 @@ impl Harness { pub async fn shutdown(mut self) { drop(self.stdin); - let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; - let _ = self.child.start_kill(); + let drain = tokio::spawn(async move { + let _ = tokio::io::copy(&mut self.stdout, &mut tokio::io::sink()).await; + }); + if tokio::time::timeout(Duration::from_secs(10), self.child.wait()) + .await + .is_err() + { + let _ = self.child.kill().await; + } + let _ = drain.await; + } + + pub async fn finish_connection(mut self) { + drop(self.stdin); + let drain = tokio::spawn(async move { + let _ = tokio::io::copy(&mut self.stdout, &mut tokio::io::sink()).await; + }); + let status = tokio::time::timeout(Duration::from_secs(8), self.child.wait()) + .await + .expect("agent cleanup timed out") + .unwrap(); + assert!(status.success(), "agent exit: {status}"); + drain.await.unwrap(); } pub fn stderr_text(&self) -> String { diff --git a/crates/buzz-agent/tests/connection_shutdown.rs b/crates/buzz-agent/tests/connection_shutdown.rs new file mode 100644 index 00000000000..fd84faa0ece --- /dev/null +++ b/crates/buzz-agent/tests/connection_shutdown.rs @@ -0,0 +1,102 @@ +//! ACP connection EOF must join session creation and MCP transport cleanup. +mod common; +use common::Harness; +use serde_json::json; +use std::{path::Path, time::Duration}; + +async fn wait_file(path: &Path) { + tokio::time::timeout(Duration::from_secs(5), async { + while !path.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn eof_joins_session_creation_and_mcp_cleanup() { + let dir = tempfile::tempdir().unwrap(); + let pid = dir.path().join("pid"); + let eof = dir.path().join("eof"); + let mut agent = Harness::spawn("http://127.0.0.1:1").await; + agent + .send( + "session/new", + json!({"cwd":dir.path(), "mcpServers":[{ + "name":"fixture", "command":env!("CARGO_BIN_EXE_fake-mcp"), "args":[], + "env":[{"name":"FAKE_MCP_INIT_DELAY_MS","value":"300"}, + {"name":"FAKE_MCP_PID_FILE","value":pid}, + {"name":"FAKE_MCP_EOF_FILE","value":eof}] + }]}), + ) + .await; + // EOF during initialization, not just after a registered session is idle. + wait_file(&pid).await; + agent.finish_connection().await; + assert_eq!(std::fs::read_to_string(eof).unwrap(), "cleanup complete"); +} + +#[tokio::test] +async fn partial_registry_error_awaits_previously_started_mcp() { + let dir = tempfile::tempdir().unwrap(); + let eof = dir.path().join("eof"); + let mut agent = Harness::spawn("http://127.0.0.1:1").await; + let id = agent + .send( + "session/new", + json!({"cwd":dir.path(), "mcpServers":[{ + "name":"fixture", "command":env!("CARGO_BIN_EXE_fake-mcp"), "args":[], + "env":[{"name":"FAKE_MCP_EOF_FILE","value":eof}] + }, {"name":"invalid__name", "command":"not-executed", "args":[], "env":[]}]}), + ) + .await; + let result = agent.recv_until(|value| value["id"] == id).await; + assert!(result.get("error").is_some(), "{result}"); + assert_eq!(std::fs::read_to_string(eof).unwrap(), "cleanup complete"); + agent.finish_connection().await; +} + +async fn shutdown_result(env: Vec) -> bool { + let dir = tempfile::tempdir().unwrap(); + let mut agent = Harness::spawn("http://127.0.0.1:1").await; + let id = agent + .send( + "session/new", + json!({"cwd":dir.path(), "mcpServers":[{ + "name":"fixture", "command":env!("CARGO_BIN_EXE_fake-mcp"), "args":[], "env":env + }]}), + ) + .await; + let session = agent.recv_until(|v| v["id"] == id).await; + assert!(session.get("result").is_some(), "{session}"); + let id = agent.send("_buzz/shutdown_v1", json!({})).await; + let response = agent.recv_until(|v| v["id"] == id).await; + agent.finish_connection().await; + response["result"]["ownedWorkStopped"].as_bool().unwrap() +} + +#[tokio::test] +async fn supported_shutdown_requires_both_explicit_work_result_and_successful_exit() { + let capability = json!({"name":"FAKE_MCP_NAMED_TOOLS","value":"_buzz_shutdown_v1"}); + assert!(shutdown_result(vec![capability.clone()]).await); + assert!( + !shutdown_result(vec![]).await, + "exit zero without capability is unknown" + ); + assert!( + !shutdown_result(vec![ + capability.clone(), + json!({"name":"FAKE_MCP_FAIL_EXIT","value":"1"}) + ]) + .await + ); + assert!( + !shutdown_result(vec![ + capability, + json!({"name":"FAKE_MCP_HANG_EXIT","value":"1"}) + ]) + .await, + "rmcp timeout-kill is not a successful teardown" + ); +} diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index edee9090d84..0f70bfe83bf 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -1444,8 +1444,9 @@ async fn cancel_kills_inflight_tool_via_mcp_notification() { ) .await; - // Wait for the tool call to be in-progress. - h.recv_until(|v| { + // Approval is required before the tool can be in-progress. This test + // exercises MCP cancellation, not a pending permission request. + h.recv_until_approving(|v| { v.get("params") .and_then(|p| p.get("update")) .and_then(|u| u.get("status")) diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 32e3a7ad16b..389b56b7ecc 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -22,7 +22,7 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { /// a known event id) still cannot read another user's private event. pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool { let kind = crate::kind::event_kind_u32(event); - if kind != crate::kind::KIND_DM_VISIBILITY && kind != crate::kind::KIND_AGENT_TURN_METRIC { + if !crate::kind::RESULT_GATED_KINDS.contains(&kind) { return true; } let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); diff --git a/crates/buzz-core/src/host.rs b/crates/buzz-core/src/host.rs new file mode 100644 index 00000000000..2277ec820d3 --- /dev/null +++ b/crates/buzz-core/src/host.rs @@ -0,0 +1,419 @@ +//! Versioned, owner-private host events. Kind 50000 is append-only, not NIP-33. +use base64::Engine; +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; + +use crate::kind::KIND_HOST; + +/// Namespace for host labels (NIP-32-shaped tags). +pub const NAMESPACE: &str = "buzz.host.v1"; +/// Maximum lifetime of a host report, independent of agent presence. +pub const REPORT_TTL: u64 = 180; + +/// Validated public routing envelope. Machine metadata is never in tags. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Envelope { + /// Owner allowed to read the event. + pub owner: PublicKey, + /// Stable host key (also the host identifier). + pub host: PublicKey, + /// Registration or report. + pub label: String, + /// Owner-signed registration referenced by a report. + pub registration: Option, + /// Readiness deadline; not a NIP-40 deletion deadline. + pub valid_until: Option, +} + +fn one<'a>(event: &'a Event, key: &str, size: usize) -> Result<&'a [String], String> { + let mut tags = event.tags.iter().filter(|t| t.as_slice()[0] == key); + let tag = tags.next().ok_or_else(|| format!("missing {key} tag"))?; + if tags.next().is_some() || tag.as_slice().len() != size { + return Err(format!("invalid {key} tag cardinality")); + } + Ok(tag.as_slice()) +} + +fn pubkey(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err("expected lowercase hex pubkey".into()); + } + PublicKey::from_hex(value).map_err(|e| e.to_string()) +} + +/// Validate routing and signatures without decrypting private machine details. +pub fn validate(event: &Event) -> Result { + if event.kind.as_u16() as u32 != KIND_HOST { + return Err("not a host event".into()); + } + crate::verify_event(event).map_err(|e| e.to_string())?; + if one(event, "L", 2)?[1] != NAMESPACE { + return Err("unknown host namespace".into()); + } + let l = one(event, "l", 3)?; + if l[2] != NAMESPACE || !matches!(l[1].as_str(), "registration" | "report" | "profile") { + return Err("unknown host label".into()); + } + let owner = pubkey(&one(event, "p", 2)?[1])?; + let host = pubkey(&one(event, "x", 2)?[1])?; + let report = l[1] != "registration"; + let allowed = if report { + if l[1] == "report" { + &["L", "l", "p", "x", "e", "valid_until"][..] + } else { + &["L", "l", "p", "x", "e"][..] + } + } else { + &["L", "l", "p", "x"][..] + }; + if event + .tags + .iter() + .any(|t| !allowed.contains(&t.as_slice()[0].as_str())) + { + return Err("unexpected host tag".into()); + } + if event.pubkey != if report { host } else { owner } { + return Err("host event signer does not match its role".into()); + } + // Reject cleartext and malformed NIP-44 envelopes before storage. The owner + // alone can authenticate/decrypt the encrypted bytes. + let bytes = base64::engine::general_purpose::STANDARD + .decode(&event.content) + .map_err(|_| "invalid encrypted host content")?; + if !(99..=36_000).contains(&bytes.len()) || bytes.first() != Some(&2) { + return Err("invalid encrypted host content".into()); + } + let (registration, valid_until) = if report { + let id = one(event, "e", 2)?[1].clone(); + if id.len() != 64 + || !id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err("invalid registration reference".into()); + } + let until = if l[1] == "report" { + let until = one(event, "valid_until", 2)?[1] + .parse::() + .map_err(|_| "invalid report deadline")?; + let ts = event.created_at.as_secs(); + if until <= ts || until > ts.saturating_add(REPORT_TTL) { + return Err("report lifetime exceeds limit".into()); + } + Some(until) + } else { + None + }; + (Some(id), until) + } else { + (None, None) + }; + Ok(Envelope { + owner, + host, + label: l[1].clone(), + registration, + valid_until, + }) +} + +/// Small allowlisted runtime projection; never commands, paths or environment. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Runtime { + /// Catalog identifier. + pub id: String, + /// Human-facing catalog label. + pub label: String, + /// Catalog availability, not an assertion that a launch will succeed. + pub availability: String, + /// Cached catalog authentication observation. + pub auth_status: String, +} + +/// Opaque reference to an already-provisioned destination configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProvisionedAgent { + /// Agent public identity, never its key. + pub agent: String, + /// Destination Rust catalog runtime ID. + pub runtime: String, + /// Digest rechecked at the actual spawn boundary. + pub revision: String, +} + +/// Encrypted machine report. Registration alone does not enable remote launch. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Report { + /// Protocol version. + pub v: u8, + /// OS-reported machine name. + pub name: String, + /// Operating system. + pub os: String, + /// CPU architecture. + pub arch: String, + /// Reporting launcher version. + pub launcher_version: String, + /// Observed catalog, with sensitive fields omitted. + pub runtimes: Vec, + /// False until the host implements launch request handling. + pub accepts_start: bool, + /// Owner-private ready configurations; absence requires destination setup. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub provisioned: Vec, +} + +impl Report { + /// Check bounded data before encryption and after authenticated decryption. + pub fn validate(&self) -> Result<(), String> { + if !matches!(self.v, 1..=3) + || (self.v != 3 && (self.accepts_start || !self.provisioned.is_empty())) + || self.runtimes.len() > 128 + || self.provisioned.len() > 256 + { + return Err("unsupported host report".into()); + } + let mut agents = std::collections::HashSet::new(); + for config in &self.provisioned { + if !crate::host_execution::hex_id(&config.agent, 64) + || !crate::host_execution::hex_id(&config.revision, 64) + || !agents.insert(&config.agent) + || !self.runtimes.iter().any(|r| { + r.id == config.runtime + && r.availability == "available" + && matches!(r.auth_status.as_str(), "logged_in" | "not_applicable") + }) + { + return Err("invalid provisioned configuration".into()); + } + } + let strings = [&self.name, &self.os, &self.arch, &self.launcher_version]; + if strings + .into_iter() + .chain( + self.runtimes + .iter() + .flat_map(|r| [&r.id, &r.label, &r.availability, &r.auth_status]), + ) + .any(|s| s.is_empty() || s.len() > 256 || s.chars().any(char::is_control)) + { + return Err("invalid host report text".into()); + } + Ok(()) + } +} + +fn build( + signer: &Keys, + owner: PublicKey, + host: PublicKey, + label: &str, + plaintext: &str, + extra: Vec>, + now: u64, +) -> Result { + let content = nip44::encrypt(signer.secret_key(), &owner, plaintext, nip44::Version::V2) + .map_err(|e| e.to_string())?; + let mut tags = vec![ + vec!["L".into(), NAMESPACE.into()], + vec!["l".into(), label.into(), NAMESPACE.into()], + vec!["p".into(), owner.to_hex()], + vec!["x".into(), host.to_hex()], + ]; + tags.extend(extra); + let tags = tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|e| e.to_string())?; + let event = EventBuilder::new(Kind::Custom(KIND_HOST as u16), content) + .allow_self_tagging() + .tags(tags) + .custom_created_at(Timestamp::from(now)) + .sign_with_keys(signer) + .map_err(|e| e.to_string())?; + validate(&event)?; + Ok(event) +} + +/// Build an owner-approved registration. Reuse an existing registration on restart. +pub fn registration(owner: &Keys, host: PublicKey, now: u64) -> Result { + build( + owner, + owner.public_key(), + host, + "registration", + r#"{"v":1}"#, + vec![], + now, + ) +} + +/// Build a report signed by the host and encrypted to the registered owner. +pub fn report( + host: &Keys, + registration: &Event, + payload: &Report, + now: u64, +) -> Result { + let env = validate(registration)?; + if env.label != "registration" || env.host != host.public_key() { + return Err("registration does not authorize this host".into()); + } + payload.validate()?; + build( + host, + env.owner, + env.host, + "report", + &serde_json::to_string(payload).map_err(|e| e.to_string())?, + vec![ + vec!["e".into(), registration.id.to_hex()], + vec![ + "valid_until".into(), + now.saturating_add(REPORT_TTL).to_string(), + ], + ], + now, + ) +} + +/// Durable change-only profile, signed by the host and encrypted to its owner. +pub fn profile( + host: &Keys, + registration: &Event, + payload: &Report, + now: u64, +) -> Result { + let env = validate(registration)?; + if env.label != "registration" || env.host != host.public_key() || !matches!(payload.v, 2 | 3) { + return Err("invalid host profile binding or version".into()); + } + payload.validate()?; + build( + host, + env.owner, + env.host, + "profile", + &serde_json::to_string(payload).map_err(|e| e.to_string())?, + vec![vec!["e".into(), registration.id.to_hex()]], + now, + ) +} + +/// Verify both signatures and their binding before decrypting machine data. +pub fn decrypt_report( + owner: &Keys, + registration: &Event, + report: &Event, +) -> Result { + let reg = validate(registration)?; + let env = validate(report)?; + if reg.label != "registration" + || !matches!(env.label.as_str(), "report" | "profile") + || env.owner != owner.public_key() + || reg.owner != env.owner + || reg.host != env.host + || env.registration.as_deref() != Some(registration.id.to_hex().as_str()) + { + return Err("host report binding mismatch".into()); + } + let text = nip44::decrypt(owner.secret_key(), &env.host, &report.content) + .map_err(|e| e.to_string())?; + let result: Report = serde_json::from_str(&text).map_err(|e| e.to_string())?; + result.validate()?; + if (env.label == "profile") != matches!(result.v, 2 | 3) { + return Err("host payload/envelope version mismatch".into()); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + fn payload() -> Report { + Report { + v: 1, + name: "Actual machine".into(), + os: "macos".into(), + arch: "aarch64".into(), + launcher_version: "test".into(), + runtimes: vec![], + accepts_start: false, + provisioned: vec![], + } + } + #[test] + fn round_trip_and_foreign_owner_rejected() { + let owner = Keys::generate(); + let host = Keys::generate(); + let reg = registration(&owner, host.public_key(), 100).unwrap(); + let rep = report(&host, ®, &payload(), 101).unwrap(); + assert_eq!( + decrypt_report(&owner, ®, &rep).unwrap().name, + "Actual machine" + ); + assert!(decrypt_report(&Keys::generate(), ®, &rep).is_err()); + assert_eq!(validate(&rep).unwrap().valid_until, Some(281)); + assert!(!rep.content.contains("Actual machine")); + for event in [®, &rep] { + assert!(crate::filter::reader_authorized_for_event( + event, + &owner.public_key().to_hex() + )); + assert!(!crate::filter::reader_authorized_for_event( + event, + &Keys::generate().public_key().to_hex() + )); + assert!(!crate::filter::reader_authorized_for_event( + event, + &host.public_key().to_hex() + )); + } + assert!(!crate::kind::is_parameterized_replaceable(KIND_HOST)); + } + #[test] + fn mismatched_registration_and_signature_rejected() { + let owner = Keys::generate(); + let host = Keys::generate(); + let reg = registration(&owner, host.public_key(), 100).unwrap(); + assert!(report(&Keys::generate(), ®, &payload(), 101).is_err()); + let rep = report(&host, ®, &payload(), 101).unwrap(); + let other = registration(&owner, host.public_key(), 99).unwrap(); + assert!(decrypt_report(&owner, &other, &rep).is_err()); + let mut tampered = rep.clone(); + tampered.content.push('A'); + assert!(validate(&tampered).is_err()); + } + #[test] + fn malformed_envelopes_rejected_even_when_signed() { + let owner = Keys::generate(); + let host = Keys::generate(); + let reg = registration(&owner, host.public_key(), 100).unwrap(); + for extra in [ + vec!["p", &owner.public_key().to_hex()], + vec!["h", "channel"], + vec!["L", "unknown"], + ] { + let mut tags: Vec = reg.tags.iter().cloned().collect(); + tags.push(Tag::parse(extra).unwrap()); + let bad = EventBuilder::new(reg.kind, reg.content.clone()) + .allow_self_tagging() + .tags(tags) + .sign_with_keys(&owner) + .unwrap(); + assert!(validate(&bad).is_err()); + } + let mut p = payload(); + p.accepts_start = true; + assert!(report(&host, ®, &p, 101).is_err()); + } +} diff --git a/crates/buzz-core/src/host_execution.rs b/crates/buzz-core/src/host_execution.rs new file mode 100644 index 00000000000..b0af2d61aa3 --- /dev/null +++ b/crates/buzz-core/src/host_execution.rs @@ -0,0 +1,518 @@ +//! Private, owner-authorized executor protocol. Registration is NOT host login. +//! +//! The caller must supply a freshly fetched, nondeleted registration from the +//! selected community, never a cached/caller-provided assertion of revocation. +//! This module is a protocol foundation; relays/executors must explicitly wire +//! authorization and a durable operation ledger before advertising Start. +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; + +use crate::{ + host, + kind::{KIND_HOST_COMMAND, KIND_HOST_RECEIPT}, +}; + +/// Maximum command lifetime; expiry is not evidence of process termination. +pub const COMMAND_TTL: u64 = 300; +/// Executor protocol namespace. +pub const NAMESPACE: &str = "buzz.host.execution.v1"; + +/// Explicit destination-local configuration, or one exact run to stop. +/// No source paths, shell strings, environment or credentials are accepted. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum Action { + /// Start only a pre-provisioned agent with this exact destination revision. + Start { + /// Rust runtime catalog ID, checked again on the destination. + runtime: String, + /// SHA-256 of destination launch configuration, not source configuration. + revision: String, + }, + /// Stop only the clicked launcher generation; never agent-wide shutdown. + Stop { + /// Public run ID, identical to the launcher's persisted generation. + run: String, + }, +} + +/// Encrypted immutable request. Retries must reuse the same signed event. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Command { + /// Protocol version. + pub v: u8, + /// Random 128-bit lowercase hex operation ID, also Start's run generation. + pub operation: String, + /// Canonical selected community URL. A command cannot cross communities. + pub relay: String, + /// Agent identity, independent of its placements. + pub agent: String, + /// Bounded execution deadline in Unix seconds. + pub expires_at: u64, + /// Requested transition. + pub action: Action, +} + +/// Process observations, never inferred from relay acceptance or online presence. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + /// Intent persisted; no process claim. + Accepted, + /// A child was created, not necessarily listening or ready. + Spawned, + /// Authenticated same-generation harness lifecycle report. + Listening, + /// Authenticated same-generation harness readiness report. + Ready, + /// Selected root and its group exited, but separately grouped descendants + /// have not been proven terminated. This MUST NOT authorize replacement. + RootExited, + /// Controller authenticated completion of the supported owned-work boundary + /// and reaped the selected root. Not a universal arbitrary-daemon guarantee. + Stopped, + /// Proven pre-side-effect rejection (safe enum, no private diagnostics). + Rejected, + /// Side effect cannot be proved; must block replacement. + Unknown, +} + +/// Encrypted host-signed result, correlated to an exact immutable command. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Receipt { + /// Protocol version. + pub v: u8, + /// Signed command event ID, not just a caller-selected operation ID. + pub command: String, + /// Original immutable request. Contains identifiers only, no launch secrets. + pub request: Command, + /// Exact process generation the observation describes. + pub run: String, + /// Original observation time, retained on receipt retransmission. + pub observed_at: u64, + /// Observed state; Unknown is not Stopped. + pub outcome: Outcome, +} + +/// Validate a fixed-length lowercase hex identifier. +pub fn hex_id(value: &str, len: usize) -> bool { + value.len() == len + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +impl Command { + /// Validate immutable structure; use `decrypt_command` for execution freshness. + pub fn validate(&self) -> Result<(), String> { + if self.v != 1 + || !hex_id(&self.operation, 32) + || !hex_id(&self.agent, 64) + || crate::relay::normalize_relay_url(&self.relay) + .ok() + .as_deref() + != Some(&self.relay) + { + return Err("invalid execution command".into()); + } + PublicKey::from_hex(&self.agent).map_err(|_| "invalid agent key")?; + match &self.action { + Action::Start { runtime, revision } + if runtime.is_empty() + || runtime.len() > 128 + || !runtime + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + || !hex_id(revision, 64) => + { + Err("invalid destination configuration reference".into()) + } + Action::Stop { run } if !hex_id(run, 32) => Err("invalid stop generation".into()), + _ => Ok(()), + } + } + + /// Generation this operation is allowed to affect. + pub fn run(&self) -> &str { + match &self.action { + Action::Start { .. } => &self.operation, + Action::Stop { run } => run, + } + } +} + +fn registration(reg: &Event) -> Result { + let binding = host::validate(reg)?; + if binding.label != "registration" { + return Err("expected current registration".into()); + } + Ok(binding) +} + +fn build( + signer: &Keys, + recipient: PublicKey, + reg: &Event, + kind: u32, + body: &impl Serialize, + now: u64, +) -> Result { + let binding = registration(reg)?; + let content = nip44::encrypt( + signer.secret_key(), + &recipient, + serde_json::to_string(body).map_err(|_| "invalid execution payload")?, + nip44::Version::V2, + ) + .map_err(|_| "execution encryption failed")?; + let tags = [ + ["L".to_owned(), NAMESPACE.into()], + ["p".into(), binding.owner.to_hex()], + ["x".into(), binding.host.to_hex()], + ["e".into(), reg.id.to_hex()], + ] + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|_| "invalid routing")?; + EventBuilder::new(Kind::Custom(kind as u16), content) + .allow_self_tagging() + .tags(tags) + .custom_created_at(Timestamp::from(now)) + .sign_with_keys(signer) + .map_err(|_| "execution signing failed".into()) +} + +fn envelope(event: &Event, reg: &Event, kind: u32, signer: PublicKey) -> Result<(), String> { + crate::verify_event(event).map_err(|_| "invalid execution signature")?; + let binding = registration(reg)?; + let expected = [ + ("L", NAMESPACE.to_owned()), + ("p", binding.owner.to_hex()), + ("x", binding.host.to_hex()), + ("e", reg.id.to_hex()), + ]; + if event.kind.as_u16() as u32 != kind + || event.pubkey != signer + || event.tags.len() != expected.len() + || event.content.len() > 16_384 + { + return Err("invalid execution envelope".into()); + } + for (key, value) in expected { + if event + .tags + .iter() + .filter(|tag| tag.as_slice() == [key, value.as_str()]) + .count() + != 1 + { + return Err("execution audience or registration mismatch".into()); + } + } + Ok(()) +} + +/// Encrypt to one registered host and sign as its owner. Existing owner transport +/// is required; possession of the host key grants no broader relay authority. +pub fn command(owner: &Keys, reg: &Event, request: &Command, now: u64) -> Result { + let binding = registration(reg)?; + if binding.owner != owner.public_key() { + return Err("foreign execution owner".into()); + } + request.validate()?; + freshness(request, now, now)?; + build(owner, binding.host, reg, KIND_HOST_COMMAND, request, now) +} + +fn freshness(request: &Command, created: u64, now: u64) -> Result<(), String> { + if created > now.saturating_add(30) + || request.expires_at <= now + || request.expires_at <= created + || request.expires_at > created.saturating_add(COMMAND_TTL) + { + return Err("execution command expired or lifetime invalid".into()); + } + Ok(()) +} + +/// Authenticate destination/owner/current registration, decrypt and check deadline +/// and community before consulting the durable dedup ledger or doing any work. +pub fn decrypt_command( + host: &Keys, + current_reg: &Event, + event: &Event, + relay: &str, + now: u64, +) -> Result { + let binding = registration(current_reg)?; + if binding.host != host.public_key() { + return Err("wrong executor".into()); + } + envelope(event, current_reg, KIND_HOST_COMMAND, binding.owner)?; + let text = nip44::decrypt(host.secret_key(), &binding.owner, &event.content) + .map_err(|_| "invalid command ciphertext")?; + let request: Command = serde_json::from_str(&text).map_err(|_| "invalid execution payload")?; + request.validate()?; + if request.relay != relay { + return Err("wrong execution community".into()); + } + freshness(&request, event.created_at.as_secs(), now)?; + Ok(request) +} + +/// Sign only an observed, durably recorded result; this helper does not turn an +/// online pulse or successful publish into an execution observation. +pub fn receipt(host: &Keys, reg: &Event, result: &Receipt, now: u64) -> Result { + let binding = registration(reg)?; + if binding.host != host.public_key() { + return Err("wrong receipt signer".into()); + } + validate_receipt(result)?; + if result.observed_at > now { + return Err("receipt observation is in the future".into()); + } + build(host, binding.owner, reg, KIND_HOST_RECEIPT, result, now) +} + +fn validate_receipt(result: &Receipt) -> Result<(), String> { + result.request.validate()?; + if result.v != 1 || !hex_id(&result.command, 64) || result.run != result.request.run() { + return Err("invalid execution receipt correlation".into()); + } + let valid = match result.request.action { + Action::Start { .. } => !matches!(result.outcome, Outcome::Stopped | Outcome::RootExited), + Action::Stop { .. } => matches!( + result.outcome, + Outcome::Accepted + | Outcome::RootExited + | Outcome::Stopped + | Outcome::Rejected + | Outcome::Unknown + ), + }; + if !valid { + return Err("invalid receipt outcome for action".into()); + } + Ok(()) +} + +/// Verify host authority and exact command/generation correlation. Late receipts +/// may resolve their original operation, never a newer operation. Unlike commands, +/// persisted results remain readable after expiry; expiry is not termination. +pub fn decrypt_receipt( + owner: &Keys, + reg: &Event, + event: &Event, + command_event: &Event, + request: &Command, +) -> Result { + let binding = registration(reg)?; + if binding.owner != owner.public_key() { + return Err("foreign receipt owner".into()); + } + envelope(command_event, reg, KIND_HOST_COMMAND, binding.owner)?; + // Bind the supplied request to the signed bytes, not caller-provided metadata. + let sent = nip44::decrypt(owner.secret_key(), &binding.host, &command_event.content) + .map_err(|_| "invalid command ciphertext")?; + let sent: Command = serde_json::from_str(&sent).map_err(|_| "invalid command payload")?; + if &sent != request { + return Err("request differs from signed command".into()); + } + envelope(event, reg, KIND_HOST_RECEIPT, binding.host)?; + let text = nip44::decrypt(owner.secret_key(), &binding.host, &event.content) + .map_err(|_| "invalid receipt ciphertext")?; + let result: Receipt = serde_json::from_str(&text).map_err(|_| "invalid receipt payload")?; + validate_receipt(&result)?; + if result.observed_at > event.created_at.as_secs() { + return Err("receipt observation is in the future".into()); + } + if result.command != command_event.id.to_hex() || &result.request != request { + return Err("receipt belongs to another operation".into()); + } + Ok(result) +} + +/// Validate the public routing envelope without decrypting execution payloads. +/// The caller must fetch this exact nondeleted registration in its community. +/// This grants only owner transport, never host login privileges. +pub fn validate_transport(event: &Event, reg: &Event, owner: PublicKey) -> Result<(), String> { + let binding = registration(reg)?; + if binding.owner != owner { + return Err("foreign execution transport owner".into()); + } + let kind = event.kind.as_u16() as u32; + let signer = match kind { + KIND_HOST_COMMAND => binding.owner, + KIND_HOST_RECEIPT => binding.host, + _ => return Err("invalid execution kind".into()), + }; + envelope(event, reg, kind, signer) +} + +#[cfg(test)] +mod tests { + use super::*; + fn request() -> Command { + Command { + v: 1, + operation: "ab".repeat(16), + relay: "wss://one.example".into(), + agent: Keys::generate().public_key().to_hex(), + expires_at: 200, + action: Action::Start { + runtime: "goose".into(), + revision: "cd".repeat(32), + }, + } + } + #[test] + fn encrypted_start_and_exact_authenticated_result() { + let owner = Keys::generate(); + let host = Keys::generate(); + let reg = host::registration(&owner, host.public_key(), 99).unwrap(); + let req = request(); + let cmd = command(&owner, ®, &req, 100).unwrap(); + assert!(!cmd.content.contains(&req.agent)); + assert!(!cmd + .tags + .iter() + .any(|t| t.as_slice().iter().any(|v| v.contains(&req.agent)))); + assert_eq!( + decrypt_command(&host, ®, &cmd, &req.relay, 101).unwrap(), + req + ); + let result = Receipt { + v: 1, + command: cmd.id.to_hex(), + request: req.clone(), + run: req.run().into(), + observed_at: 101, + outcome: Outcome::Spawned, + }; + let event = receipt(&host, ®, &result, 101).unwrap(); + assert!(receipt(&host, ®, &result, 100).is_err()); + let replay = receipt(&host, ®, &result, 300).unwrap(); + assert_eq!( + decrypt_receipt(&owner, ®, &replay, &cmd, &req) + .unwrap() + .observed_at, + 101 + ); + assert_eq!( + decrypt_receipt(&owner, ®, &event, &cmd, &req).unwrap(), + result + ); + let mut different = req.clone(); + different.operation = "ef".repeat(16); + assert!(decrypt_receipt(&owner, ®, &event, &cmd, &different).is_err()); + assert!(decrypt_receipt(&Keys::generate(), ®, &event, &cmd, &req).is_err()); + } + #[test] + fn rejects_wrong_owner_host_tenant_registration_expiry_and_tampering() { + let owner = Keys::generate(); + let host = Keys::generate(); + let req = request(); + let reg = host::registration(&owner, host.public_key(), 99).unwrap(); + assert!(command(&Keys::generate(), ®, &req, 100).is_err()); + let cmd = command(&owner, ®, &req, 100).unwrap(); + assert!(decrypt_command(&Keys::generate(), ®, &cmd, &req.relay, 101).is_err()); + assert!(decrypt_command(&host, ®, &cmd, "wss://two.example", 101).is_err()); + assert!(decrypt_command(&host, ®, &cmd, &req.relay, 200).is_err()); + assert!(decrypt_command(&host, ®, &cmd, &req.relay, 60).is_err()); + let renewed = host::registration(&owner, host.public_key(), 100).unwrap(); + assert!(decrypt_command(&host, &renewed, &cmd, &req.relay, 101).is_err()); + let mut tampered = cmd; + tampered.content.push('x'); + assert!(decrypt_command(&host, ®, &tampered, &req.relay, 101).is_err()); + } + #[test] + fn exact_stop_and_confused_outcomes_rejected() { + let owner = Keys::generate(); + let host = Keys::generate(); + let reg = host::registration(&owner, host.public_key(), 99).unwrap(); + let mut req = request(); + req.action = Action::Stop { + run: "12".repeat(16), + }; + let cmd = command(&owner, ®, &req, 100).unwrap(); + let mut result = Receipt { + v: 1, + command: cmd.id.to_hex(), + request: req.clone(), + run: req.run().into(), + observed_at: 101, + outcome: Outcome::Stopped, + }; + assert!(receipt(&host, ®, &result, 201).is_ok()); + result.run = req.operation.clone(); + assert!(receipt(&host, ®, &result, 201).is_err()); + result.run = req.run().into(); + result.outcome = Outcome::Ready; + assert!(receipt(&host, ®, &result, 201).is_err()); + req.expires_at = 401; + assert!(command(&owner, ®, &req, 100).is_err()); + } +} + +#[cfg(test)] +mod transport_tests { + use super::*; + #[test] + fn private_transport_requires_exact_owner_registration_and_host_signature() { + let owner = Keys::generate(); + let host = Keys::generate(); + let stranger = Keys::generate(); + let reg = host::registration(&owner, host.public_key(), 100).unwrap(); + let request = Command { + v: 1, + operation: "ab".repeat(16), + relay: "wss://relay.example".into(), + agent: Keys::generate().public_key().to_hex(), + expires_at: 400, + action: Action::Start { + runtime: "goose".into(), + revision: "cd".repeat(32), + }, + }; + let command = command(&owner, ®, &request, 100).unwrap(); + let result = Receipt { + v: 1, + command: command.id.to_hex(), + run: request.run().into(), + request: request.clone(), + observed_at: 101, + outcome: Outcome::Spawned, + }; + let receipt = receipt(&host, ®, &result, 101).unwrap(); + for event in [&command, &receipt] { + assert!(validate_transport(event, ®, owner.public_key()).is_ok()); + assert!(validate_transport(event, ®, host.public_key()).is_err()); + assert!(validate_transport(event, ®, stranger.public_key()).is_err()); + assert!(validate_transport( + event, + &host::registration(&owner, host.public_key(), 99).unwrap(), + owner.public_key() + ) + .is_err()); + assert!(!crate::filter::reader_authorized_for_event( + event, + &stranger.public_key().to_hex() + )); + assert!(crate::filter::reader_authorized_for_event( + event, + &owner.public_key().to_hex() + )); + let forged = EventBuilder::new(event.kind, event.content.clone()) + .tags(event.tags.clone()) + .allow_self_tagging() + .sign_with_keys(&stranger) + .unwrap(); + assert!(validate_transport(&forged, ®, owner.public_key()).is_err()); + } + assert!(decrypt_command(&host, ®, &command, &request.relay, 400).is_err()); + assert!(decrypt_receipt(&owner, ®, &receipt, &command, &request).is_ok()); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913d..5f4d73f091e 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -117,6 +117,14 @@ pub const KIND_PUSH_LEASE: u32 = 30350; /// plus exact public projection bindings. See `docs/nips/NIP-PMA.md`. pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; +/// Private host registration and reports, distinguished by `buzz.host.v1` labels. +/// Append-only; reports are host-signed and readable only by the tagged owner. +pub const KIND_HOST: u32 = 50000; +/// Owner-signed, destination-encrypted execution command (not yet relay-enabled). +pub const KIND_HOST_COMMAND: u32 = 50001; +/// Host-signed, owner-encrypted execution observation (not yet relay-enabled). +pub const KIND_HOST_RECEIPT: u32 = 50002; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -139,7 +147,13 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ /// /// Used by `filter_can_match_result_gated_kinds` to force the per-event /// fallback path in COUNT rather than the fast SQL `count_events()`. -pub const RESULT_GATED_KINDS: &[u32] = &[KIND_DM_VISIBILITY, KIND_AGENT_TURN_METRIC]; +pub const RESULT_GATED_KINDS: &[u32] = &[ + KIND_DM_VISIBILITY, + KIND_AGENT_TURN_METRIC, + KIND_HOST, + KIND_HOST_COMMAND, + KIND_HOST_RECEIPT, +]; /// Kinds whose stored events have `#p`-bound read access — readable only by /// subscribers whose pubkey appears in the event's `#p` tag. @@ -157,6 +171,9 @@ pub const RESULT_GATED_KINDS: &[u32] = &[KIND_DM_VISIBILITY, KIND_AGENT_TURN_MET /// included for filter-layer enforcement but are never stored, so the /// storage-layer search defense does not apply to them. pub const P_GATED_KINDS: &[u32] = &[ + KIND_HOST, + KIND_HOST_COMMAND, + KIND_HOST_RECEIPT, KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, @@ -633,6 +650,9 @@ pub const KIND_PROJECT: u32 = 30621; /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ + KIND_HOST, + KIND_HOST_COMMAND, + KIND_HOST_RECEIPT, KIND_PROFILE, KIND_TEXT_NOTE, KIND_CONTACT_LIST, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3b..a101abd274e 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod event; pub mod filter; /// Git permission types — ref patterns, protection rules, policy evaluation. pub mod git_perms; +/// Private host registration and expiring reports. +pub mod host; /// Shared invite-link contract constants. pub mod invite; /// Buzz kind number registry — custom event type constants. @@ -30,6 +32,8 @@ pub mod network; pub mod nip10; /// Agent observer frame helpers. pub mod observer; +/// Authenticated local owned-work teardown evidence. +pub mod owned_stop; /// NIP-AB device pairing — crypto primitives, message types, and errors. pub mod pairing; /// Presence status types shared across crates. @@ -79,3 +83,9 @@ pub mod test_helpers { StoredEvent::with_received_at(make_event(kind), Utc::now(), channel_id, true) } } + +/// Per-run presence and public placement wire contract. +pub mod run_presence; + +/// Authenticated, private host execution protocol foundations. +pub mod host_execution; diff --git a/crates/buzz-core/src/owned_stop.rs b/crates/buzz-core/src/owned_stop.rs new file mode 100644 index 00000000000..bcf193820e4 --- /dev/null +++ b/crates/buzz-core/src/owned_stop.rs @@ -0,0 +1,83 @@ +//! Local supported-runtime completion evidence, not a relay event or a second +//! run identity. The host still signs the public execution Receipt. This proof +//! authenticates the retained harness's explicit child-work result to that host. +use nostr::{ + hashes::{sha256::Hash as Sha256Hash, Hash}, + secp256k1::{schnorr::Signature, Message}, + Keys, PublicKey, SECP256K1, +}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +/// Domain-separated, agent-signed assertion for one existing launcher generation. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Proof { + /// Canonical community URL. + pub relay: String, + /// Existing launcher nonce (never a new supervisor-generated run ID). + pub run: String, + /// BIP-340 signature; the expected agent key is supplied by the host. + pub signature: String, +} +fn message(agent: &PublicKey, relay: &str, run: &str) -> Result { + if !crate::host_execution::hex_id(run, 32) + || crate::relay::normalize_relay_url(relay).ok().as_deref() != Some(relay) + { + return Err("invalid owned-work proof scope".into()); + } + // JSON array is unambiguous; fixed domain commits to v1's supported boundary. + let preimage = serde_json::to_vec(&["buzz.owned-work.stopped.v1", &agent.to_hex(), relay, run]) + .map_err(|_| "cannot encode owned-work proof")?; + Ok(Message::from_digest( + Sha256Hash::hash(&preimage).to_byte_array(), + )) +} +/// Sign only after every owned child supplied supported completion evidence and +/// was reaped successfully. Never infer this assertion from root exit alone. +pub fn sign(keys: &Keys, relay: &str, run: &str) -> Result { + let message = message(&keys.public_key(), relay, run)?; + Ok(Proof { + relay: relay.into(), + run: run.into(), + signature: keys.sign_schnorr(&message).to_string(), + }) +} +/// Validate a bounded local proof against the selected placement, identity and +/// generation. The caller must separately reap its retained root before using it. +pub fn verify(proof: &Proof, agent: &str, relay: &str, run: &str) -> Result<(), String> { + if proof.relay != relay || proof.run != run { + return Err("owned-work proof scope mismatch".into()); + } + let key = PublicKey::from_hex(agent).map_err(|_| "invalid agent key")?; + let sig = Signature::from_str(&proof.signature).map_err(|_| "invalid proof signature")?; + SECP256K1 + .verify_schnorr( + &sig, + &message(&key, relay, run)?, + &key.xonly().map_err(|_| "invalid agent key")?, + ) + .map_err(|_| "invalid owned-work proof".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn proof_binds_identity_community_generation_and_result_domain() { + let keys = Keys::generate(); + let relay = "wss://example.com"; + let run = "aa".repeat(16); + let proof = sign(&keys, relay, &run).unwrap(); + assert!(verify(&proof, &keys.public_key().to_hex(), relay, &run).is_ok()); + assert!(verify(&proof, &Keys::generate().public_key().to_hex(), relay, &run).is_err()); + assert!(verify( + &proof, + &keys.public_key().to_hex(), + "wss://peer.example.com", + &run + ) + .is_err()); + assert!(verify(&proof, &keys.public_key().to_hex(), relay, &"bb".repeat(16)).is_err()); + } +} diff --git a/crates/buzz-core/src/run_presence.rs b/crates/buzz-core/src/run_presence.rs new file mode 100644 index 00000000000..ef9cae64b0a --- /dev/null +++ b/crates/buzz-core/src/run_presence.rs @@ -0,0 +1,184 @@ +//! Bounded per-run presence. Location is a launcher assertion, not launch authority. +use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; + +/// Presence lifetime, shared by publishers, relay and consumers. +pub const LEASE_SECONDS: u64 = 180; +/// A single live launcher run; offline records remain as ordering tombstones. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunPresence { + /// Random process-generation identifier (32 lowercase hex characters). + pub run: String, + /// Monotonically increasing pulse sequence within this generation. + pub seq: u64, + /// Bare online, away or offline status. + pub status: String, + /// Actual publisher deadline, never renewed by reading a snapshot. + pub expires_at: u64, + /// Deliberately public host reference and display alias, if supplied. + pub location: Option, + /// Owner-signed host binding for owner-transported host pulses only. + pub registration: Option, +} +/// Minimal public placement descriptor; never includes private capabilities. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Location { + /// Stable launcher installation key. + pub host: String, + /// Public display alias, not an implicitly exported OS hostname. + pub label: String, +} +fn hex(s: &str, len: usize) -> bool { + s.len() == len + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +impl Location { + /// Reject unbounded, misleading control text and malformed identifiers. + pub fn validate(&self) -> Result<(), String> { + if !hex(&self.host, 64) + || PublicKey::from_hex(&self.host).is_err() + || self.label.trim().is_empty() + || self.label.len() > 80 + || self.label.chars().any(|c| { + c.is_control() || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') + }) + { + return Err("invalid public host location".into()); + } + Ok(()) + } +} +/// Parse the upgraded wire format; legacy no-run presence stays on its old path. +/// Callers must separately verify event signature and transport authority. +pub fn parse_run(event: &Event, now: u64) -> Result, String> { + if !event.tags.iter().any(|t| { + matches!( + t.as_slice()[0].as_str(), + "run" | "seq" | "host" | "host_registration" + ) + }) { + return Ok(None); + } + if event.kind.as_u16() as u32 != crate::kind::KIND_PRESENCE_UPDATE + || !matches!(event.content.as_str(), "online" | "away" | "offline") + { + return Err("invalid run presence status".into()); + } + let ts = event.created_at.as_secs(); + if ts > now.saturating_add(30) || ts.saturating_add(LEASE_SECONDS) <= now { + return Err("run presence timestamp outside lease window".into()); + } + let mut run = None; + let mut seq = None; + let mut location = None; + let mut registration = None; + for tag in event.tags.iter() { + let t = tag.as_slice(); + match t[0].as_str() { + "run" if t.len() == 2 && run.is_none() && hex(&t[1], 32) => run = Some(t[1].clone()), + "seq" if t.len() == 2 && seq.is_none() => { + let n = t[1] + .parse::() + .map_err(|_| "invalid presence sequence")?; + // Lua compares exactly representable integers. + if n > 9_007_199_254_740_991 { + return Err("presence sequence too large".into()); + } + seq = Some(n); + } + "host" if t.len() == 3 && location.is_none() => { + let l = Location { + host: t[1].clone(), + label: t[2].clone(), + }; + l.validate()?; + location = Some(l); + } + "host_registration" if t.len() == 2 && registration.is_none() && hex(&t[1], 64) => { + registration = Some(t[1].clone()) + } + _ => return Err("invalid run presence tags".into()), + } + } + Ok(Some(RunPresence { + run: run.ok_or("missing presence run")?, + seq: seq.ok_or("missing presence sequence")?, + status: event.content.clone(), + expires_at: ts + .saturating_add(LEASE_SECONDS) + .min(now.saturating_add(LEASE_SECONDS)), + location, + registration, + })) +} +/// Sign one pulse with a process-stable run and increasing sequence. +pub fn pulse( + keys: &Keys, + run: &str, + seq: u64, + status: &str, + location: Option<&Location>, + registration: Option<&str>, + now: u64, +) -> Result { + let mut tags = vec![ + vec!["run".into(), run.into()], + vec!["seq".into(), seq.to_string()], + ]; + if let Some(l) = location { + tags.push(vec!["host".into(), l.host.clone(), l.label.clone()]); + } + if let Some(id) = registration { + tags.push(vec!["host_registration".into(), id.into()]); + } + let tags = tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|e| e.to_string())?; + let event = EventBuilder::new( + Kind::Custom(crate::kind::KIND_PRESENCE_UPDATE as u16), + status, + ) + .tags(tags) + .custom_created_at(Timestamp::from(now)) + .sign_with_keys(keys) + .map_err(|e| e.to_string())?; + parse_run(&event, now)?; + Ok(event) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn run_wire_is_bounded_and_legacy_is_separate() { + let keys = Keys::generate(); + let location = Location { + host: Keys::generate().public_key().to_hex(), + label: "Workshop".into(), + }; + let event = pulse( + &keys, + &"a".repeat(32), + 1, + "online", + Some(&location), + None, + 100, + ) + .unwrap(); + let parsed = parse_run(&event, 110).unwrap().unwrap(); + assert_eq!(parsed.expires_at, 280); + assert_eq!(parsed.location, Some(location)); + assert!(parse_run(&event, 280).is_err()); + assert!(parse_run(&event, 69).is_err()); + assert!(pulse(&keys, "bad", 0, "online", None, None, 100).is_err()); + assert!(pulse(&keys, &"a".repeat(32), 0, "ready", None, None, 100).is_err()); + let legacy = EventBuilder::new(Kind::Custom(20001), "online") + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(parse_run(&legacy, 100).unwrap(), None); + } +} diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 00cd81c6940..4442ba462a6 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -699,7 +699,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 40); + assert_eq!(migrations.len(), 42); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -907,8 +907,20 @@ mod tests { assert!(migrations[32].sql.as_str().contains("kind = 30179")); assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); - assert!(include_str!("../../../../schema/schema.sql") - .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)")); + assert!(include_str!("../../../../schema/schema.sql").contains( + "kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200, 50000, 50001, 50002)" + )); + // Host privacy is additive too; never rewrite historical checksums. + assert_eq!(migrations[40].version, 41); + assert!(migrations[40].sql.as_str().contains("kind = 50000")); + assert!(!migrations[0].sql.as_str().contains("50000")); + assert_eq!(migrations[41].version, 42); + assert!(migrations[41] + .sql + .as_str() + .contains("kind IN (50001, 50002)")); + assert!(migrations[41].sql.as_str().contains("existing_expression")); + assert!(!migrations[40].sql.as_str().contains("50001")); // Public push-gateway authority is intentionally deployment-global and // durable: immediate revocation and hostile-relay admission cannot be diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 60e6b05ef9b..0a8a1ee5e2b 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -32,6 +32,28 @@ pub use crate::reminder::{ /// the advertised ceiling and the enforced one cannot drift. pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000; +// Query and COUNT share the core matcher's generic-tag semantics. In particular, +// #h falls back to the stored channel only when NO explicit h tag exists. +fn push_exact_tags( + qb: &mut QueryBuilder, + tags: &[(String, Vec)], + col_prefix: &str, +) { + for (name, values) in tags { + qb.push(format!(" AND (EXISTS (SELECT 1 FROM jsonb_array_elements({col_prefix}tags) AS exact_tag WHERE exact_tag->>0 = ")) + .push_bind(name) + .push(" AND exact_tag->>1 = ANY(") + .push_bind(values) + .push("))"); + if name == "h" { + qb.push(format!(" OR (NOT EXISTS (SELECT 1 FROM jsonb_array_elements({col_prefix}tags) AS channel_tag WHERE channel_tag->>0 = 'h') AND {col_prefix}channel_id::text = ANY(")) + .push_bind(values) + .push("))"); + } + qb.push(")"); + } +} + /// Optional filters for [`query_events`]. #[derive(Debug, Clone)] pub struct EventQuery { @@ -81,6 +103,12 @@ pub struct EventQuery { /// Restrict results to events with an exact custom tag pair. /// Uses JSONB containment against `tags` before SQL `LIMIT`. pub custom_tag: Option<(String, String)>, + /// Exact tag-name/value filters applied before LIMIT. Names are AND-ed, + /// values within each name are OR-ed, matching NIP-01 generic tag semantics. + /// `h` uses stored channel_id only in the absence of explicit h tags, + /// matching the core filter matcher. Host inventory uses this instead of + /// best-effort mention indexes. + pub exact_tags: Vec<(String, Vec)>, /// Restrict results to events in any of these channels. By default, /// channel-less global events are retained so this can enforce a viewer's /// accessible-channel scope without hiding global events. Set @@ -140,6 +168,7 @@ impl EventQuery { ids: None, e_tags: None, custom_tag: None, + exact_tags: Vec::new(), channel_ids: None, channel_ids_include_global: true, max_limit: None, @@ -525,6 +554,8 @@ pub(crate) async fn query_events_on( .push_bind(containment); } + push_exact_tags(&mut qb, &q.exact_tags, col_prefix); + if let Some(s) = q.since { qb.push(format!(" AND {col_prefix}created_at >= ")) .push_bind(s); @@ -784,6 +815,8 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer } } + push_exact_tags(&mut qb, &q.exact_tags, col_prefix); + if let Some(s) = q.since { qb.push(format!(" AND {col_prefix}created_at >= ")) .push_bind(s); diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index 87c3a119317..d4825456441 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -3,9 +3,7 @@ use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{CallToolResult, ServerCapabilities, ServerInfo}, - tool, tool_handler, tool_router, - transport::stdio, - ErrorData, ServerHandler, ServiceExt, + tool, tool_handler, tool_router, ErrorData, ServerHandler, ServiceExt, }; use std::path::Path; use std::sync::Arc; @@ -19,6 +17,7 @@ mod str_replace; mod todo; mod tree; mod view_image; +mod workloads; #[derive(Clone)] struct DevMcp { @@ -37,6 +36,23 @@ impl DevMcp { } } + // Hidden from the model by buzz-agent's underscore-tool rule. Discovery + // of this exact versioned tool is the supported teardown capability. + #[tool( + name = "_buzz_shutdown_v1", + description = "Close shell admission and confirm owned process-group work has ended. Supervisor only." + )] + async fn shutdown_owned_work(&self) -> Result { + self.state + .workloads + .drain() + .await + .map_err(|_| ErrorData::internal_error("owned work teardown unconfirmed", None))?; + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + "buzz.owned-work.stopped.v1", + )])) + } + #[tool( name = "shell", description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 600000 (10 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g -C --files), tree (flags: -d ; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)." @@ -180,9 +196,34 @@ async fn async_main(cmd: String) -> Result<(), Box> { let shim = shim::Shim::install()?; let state = Arc::new(shell::SharedState::new(cwd, shim)?); - let service = DevMcp::new(state).serve(stdio()).await?; - service.waiting().await?; - Ok(()) + serve_connection(state, tokio::io::stdin(), tokio::io::stdout()) + .await + .map_err(|e| e as Box) +} + +async fn serve_connection( + state: Arc, + reader: R, + writer: W, +) -> Result<(), Box> +where + R: tokio::io::AsyncRead + Send + Unpin + 'static, + W: tokio::io::AsyncWrite + Send + Unpin + 'static, +{ + let input = workloads::CancelOnEof { + reader, + state: state.clone(), + }; + let result = async { + let service = DevMcp::new(state.clone()).serve((input, writer)).await?; + service.waiting().await?; + Ok::<_, Box>(()) + } + .await; + // Also covers failed initialization and broken stdout. Never leave runtime + // destruction to race the independently grouped shell cleanup futures. + state.workloads.drain().await?; + result } /// Suppress the console window that Windows otherwise allocates for every diff --git a/crates/buzz-dev-mcp/src/shell.rs b/crates/buzz-dev-mcp/src/shell.rs index 7aa95b1d879..bd636014fae 100644 --- a/crates/buzz-dev-mcp/src/shell.rs +++ b/crates/buzz-dev-mcp/src/shell.rs @@ -24,6 +24,7 @@ const ARTIFACT_RING_SIZE: usize = 8; const READ_CHUNK: usize = 16 * 1024; pub struct SharedState { + pub(crate) workloads: crate::workloads::Workloads, pub cwd: PathBuf, pub shim: Shim, pub session_dir: TempDir, @@ -52,6 +53,7 @@ impl SharedState { }; let bootstrap_instructions = build_bootstrap(&cwd, shell_hint); Ok(Self { + workloads: Default::default(), cwd, shim, session_dir, @@ -132,6 +134,13 @@ pub async fn run( p: ShellParams, ct: CancellationToken, ) -> Result { + let _work = state + .workloads + .enter() + .ok_or_else(|| ErrorData::internal_error("shell connection is shutting down", None))?; + if state.workloads.cancel.is_cancelled() { + return Ok(CallToolResult::error(vec![Content::text("cancelled")])); + } if p.command.len() > MAX_COMMAND_BYTES { return Err(ErrorData::invalid_params( format!("command exceeds {MAX_COMMAND_BYTES} byte limit"), @@ -190,6 +199,7 @@ pub async fn run( }; let pid = child.id(); + let mut owned_work = state.workloads.child(); // KillGroup ties the spawned bash and all its descendants to a single kill // primitive (Unix process group / Windows Job Object). Built from the live @@ -217,7 +227,12 @@ pub async fn run( let mut notes: Vec = Vec::new(); let (status, timed_out) = tokio::select! { biased; - _ = ct.cancelled() => { + _ = async { + tokio::select! { + _ = ct.cancelled() => {} + _ = state.workloads.cancel.cancelled() => {} + } + } => { // Kill process group, reap child, abort reader tasks. kill_group.kill_immediate(); // Bounded reap so we don't leak zombies. If reap times out, @@ -232,8 +247,11 @@ pub async fn run( tracing::debug!("cancel: child reap timed out; guard will kill on drop"); } } + owned_work.finish(pid, matches!(child.try_wait(), Ok(Some(_)))).await; stdout_handle.abort(); stderr_handle.abort(); + let _ = stdout_handle.await; + let _ = stderr_handle.await; return Ok(CallToolResult::error(vec![Content::text("cancelled")])); } r = tokio::time::timeout(timeout_dur, child.wait()) => match r { @@ -319,6 +337,9 @@ pub async fn run( "notes": notes, }); let text = serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into()); + owned_work + .finish(pid, matches!(child.try_wait(), Ok(Some(_)))) + .await; kill_group.disarm(); Ok(CallToolResult::success(vec![Content::text(text)])) } diff --git a/crates/buzz-dev-mcp/src/workloads.rs b/crates/buzz-dev-mcp/src/workloads.rs new file mode 100644 index 00000000000..8ccf8149ab8 --- /dev/null +++ b/crates/buzz-dev-mcp/src/workloads.rs @@ -0,0 +1,124 @@ +//! Connection-owned shell work. EOF is a cancellation boundary, not permission +//! to drop the runtime while independently grouped shell children still run. +use std::io; +use std::pin::Pin; +use std::sync::Mutex; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio_util::sync::CancellationToken; +use tokio_util::task::task_tracker::TaskTrackerToken; +use tokio_util::task::TaskTracker; + +#[derive(Default)] +pub(crate) struct Workloads { + closed: Mutex, + failed: std::sync::atomic::AtomicBool, + tasks: TaskTracker, + pub(crate) cancel: CancellationToken, +} + +impl Workloads { + pub(crate) fn enter(&self) -> Option { + let closed = self.closed.lock().ok()?; + if *closed { + None + } else { + // Admission and close are serialized: wait cannot miss a shell + // whose handler was scheduled just as the transport ended. + Some(self.tasks.token()) + } + } + + pub(crate) fn close(&self) { + if let Ok(mut closed) = self.closed.lock() { + *closed = true; + } + self.cancel.cancel(); + self.tasks.close(); + } + + pub(crate) fn child(&self) -> OwnedWork<'_> { + OwnedWork { + owner: self, + complete: false, + } + } + + pub(crate) async fn drain(&self) -> io::Result<()> { + self.close(); + tokio::time::timeout(std::time::Duration::from_secs(3), self.tasks.wait()) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "shell cleanup did not finish"))?; + if self.failed.load(std::sync::atomic::Ordering::Acquire) { + return Err(io::Error::other("owned shell cleanup unconfirmed")); + } + Ok(()) + } +} + +/// Observe input EOF before rmcp's response-drain timeout. That gives the shell +/// owner time to kill its process group, wait its child and join output readers. +pub(crate) struct CancelOnEof { + pub(crate) reader: R, + pub(crate) state: std::sync::Arc, +} + +impl AsyncRead for CancelOnEof { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let before = buf.filled().len(); + let remaining = buf.remaining(); + let result = Pin::new(&mut this.reader).poll_read(cx, buf); + if matches!(result, Poll::Ready(Err(_))) + || (matches!(result, Poll::Ready(Ok(()))) + && remaining > 0 + && buf.filled().len() == before) + { + this.state.workloads.close(); + } + result + } +} + +#[cfg(test)] +mod tests; + +/// Dropping a shell future is not completion evidence. +pub(crate) struct OwnedWork<'a> { + owner: &'a Workloads, + complete: bool, +} +impl OwnedWork<'_> { + pub(crate) async fn finish(&mut self, pid: Option, reaped: bool) { + if !reaped { + return; + } + #[cfg(unix)] + if let Some(pid) = pid { + use nix::{errno::Errno, sys::signal::killpg, unistd::Pid}; + for _ in 0..50 { + if killpg(Pid::from_raw(pid as i32), None) == Err(Errno::ESRCH) { + self.complete = true; + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + // Other platforms lack a supported observation here; fail closed. + #[cfg(not(unix))] + let _ = pid; + } +} +impl Drop for OwnedWork<'_> { + fn drop(&mut self) { + if !self.complete { + self.owner + .failed + .store(true, std::sync::atomic::Ordering::Release); + } + } +} diff --git a/crates/buzz-dev-mcp/src/workloads/tests.rs b/crates/buzz-dev-mcp/src/workloads/tests.rs new file mode 100644 index 00000000000..b5cc9df0c61 --- /dev/null +++ b/crates/buzz-dev-mcp/src/workloads/tests.rs @@ -0,0 +1,132 @@ +use super::*; + +#[tokio::test] +async fn closed_owner_rejects_late_admission_and_waits_for_existing_work() { + let owner = Workloads::default(); + let work = owner.enter().unwrap(); + owner.close(); + assert!(owner.enter().is_none()); + assert!(owner.cancel.is_cancelled()); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), owner.drain()) + .await + .is_err() + ); + drop(work); + owner.drain().await.unwrap(); + owner.drain().await.unwrap(); +} + +#[cfg(unix)] +#[tokio::test] +async fn mcp_eof_reaps_separate_shell_group_without_stopping_peer() { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("children"); + let state = std::sync::Arc::new( + crate::shell::SharedState::new(dir.path().into(), crate::shim::Shim::install().unwrap()) + .unwrap(), + ); + let (client, server) = tokio::io::duplex(64 * 1024); + let (reader, writer) = tokio::io::split(server); + let owned = state.clone(); + let server_task = tokio::spawn(async move { + crate::serve_connection(owned, reader, writer) + .await + .map_err(|e| e.to_string()) + }); + let (mut reader, mut writer) = tokio::io::split(client); + let output = tokio::spawn(async move { + let mut bytes = vec![]; + reader.read_to_end(&mut bytes).await.unwrap(); + bytes + }); + for message in [ + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"teardown-test","version":"1"} + }}), + serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{ + "name":"shell","arguments":{"command":format!("sleep 30 & echo \"$$ $!\" > '{}'; wait", marker.display()),"timeout_ms":10000} + }}), + ] { + writer + .write_all(format!("{message}\n").as_bytes()) + .await + .unwrap(); + } + let mut peer = tokio::process::Command::new("/bin/sleep") + .arg("30") + .process_group(0) + .kill_on_drop(true) + .spawn() + .unwrap(); + let pids = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(text) = std::fs::read_to_string(&marker) { + let pids: Vec = text + .split_whitespace() + .filter_map(|p| p.parse().ok()) + .collect(); + if pids.len() == 2 { + break pids; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + let group = pids.as_ref().ok().map(|pids| { + std::process::Command::new("/bin/ps") + .args(["-o", "pgid=", "-p", &pids[0].to_string()]) + .output() + .unwrap() + }); + // Always trigger cleanup before asserting fixture readiness. + writer.shutdown().await.unwrap(); + drop(writer); + // Finish before rmcp's five-second EOF response-drain timeout: cleanup + // starts at EOF, not only after the transport has already given up. + let result = tokio::time::timeout(Duration::from_secs(4), server_task).await; + state.workloads.close(); + let peer_alive = peer.try_wait().unwrap().is_none(); + peer.kill().await.unwrap(); + peer.wait().await.unwrap(); + let pids = pids.unwrap(); + let group = group.unwrap(); + assert!(group.status.success()); + assert_eq!( + String::from_utf8_lossy(&group.stdout).trim(), + pids[0].to_string() + ); + result.unwrap().unwrap().unwrap(); + output.await.unwrap(); + assert!(peer_alive, "a separate placement is not a teardown target"); + // A real shell and its child occupied their own group, not the MCP/root + // group. EOF must make the natural shell owner cancel and reap them. + for pid in pids { + let gone = tokio::time::timeout(Duration::from_secs(3), async { + while kill(Pid::from_raw(pid), None).is_ok() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + if gone.is_err() { + let _ = kill(Pid::from_raw(pid), Signal::SIGKILL); + } + assert!(gone.is_ok(), "managed child {pid} survived MCP EOF"); + } + assert!(state.workloads.enter().is_none()); +} + +#[tokio::test] +async fn abandoned_owned_child_prevents_success_even_when_task_count_is_zero() { + let owner = Workloads::default(); + drop(owner.child()); + assert!(owner.drain().await.is_err()); + assert!(owner.drain().await.is_err()); +} diff --git a/crates/buzz-pubsub/src/error.rs b/crates/buzz-pubsub/src/error.rs index 96bc1016c9b..f216d79ee03 100644 --- a/crates/buzz-pubsub/src/error.rs +++ b/crates/buzz-pubsub/src/error.rs @@ -3,6 +3,9 @@ use thiserror::Error; /// Errors that can occur in pub/sub, presence, and typing operations. #[derive(Debug, Error)] pub enum PubSubError { + /// Bounded per-identity presence run capacity reached. + #[error("presence run limit reached")] + PresenceRunLimit, /// A Redis command failed. #[error("Redis error: {0}")] Redis(#[from] redis::RedisError), diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 4f1690beefb..b0fde8a9524 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -36,6 +36,8 @@ pub mod presence; pub mod publisher; /// Redis-backed rate limiter (fixed-window INCR + EXPIRE). pub mod rate_limiter; +/// Per-run presence leases and ordering fences. +pub mod run_presence; /// Redis SUBSCRIBE for channel event delivery. pub mod subscriber; /// Community-scoped Redis event topics. @@ -328,6 +330,26 @@ impl PubSubManager { publisher::publish_event(&self.pool, ctx, topic, event).await } + /// Accept a newer per-run pulse without erasing other placements. + pub async fn update_run_presence( + &self, + ctx: &TenantContext, + author: &PublicKey, + pulse: &buzz_core::run_presence::RunPresence, + now: u64, + ) -> Result { + run_presence::update(&self.pool, ctx, author, pulse, now).await + } + /// Return unexpired runs with original expiry deadlines. + pub async fn active_presence_runs( + &self, + ctx: &TenantContext, + author: &PublicKey, + now: u64, + ) -> Result, PubSubError> { + run_presence::active(&self.pool, ctx, author, now).await + } + /// Set presence with 180s TTL. Call on connect and every 60s heartbeat. pub async fn set_presence( &self, @@ -368,8 +390,11 @@ impl PubSubManager { #[cfg(test)] pub(crate) mod test_util { + pub fn redis_url() -> String { + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()) + } pub fn make_test_pool() -> deadpool_redis::Pool { - let cfg = deadpool_redis::Config::from_url("redis://127.0.0.1:6379"); + let cfg = deadpool_redis::Config::from_url(redis_url()); cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1)) .expect("Failed to create Redis pool") } @@ -386,7 +411,7 @@ mod tests { async fn make_manager() -> Arc { let pool = make_test_pool(); Arc::new( - PubSubManager::new("redis://127.0.0.1:6379", pool) + PubSubManager::new(&crate::test_util::redis_url(), pool) .await .expect("Failed to create PubSubManager"), ) @@ -513,7 +538,7 @@ mod tests { let pool = make_test_pool(); let manager = Arc::new( PubSubManager::with_config( - PubSubConfig::new("redis://127.0.0.1:6379") + PubSubConfig::new(crate::test_util::redis_url()) .with_unsubscribe_debounce(Duration::from_millis(25)), pool, ) @@ -593,7 +618,7 @@ mod tests { async fn retain_release_refcounts_and_debounces_last_release() { let pool = make_test_pool(); let manager = PubSubManager::with_config( - PubSubConfig::new("redis://127.0.0.1:6379") + PubSubConfig::new(crate::test_util::redis_url()) .with_unsubscribe_debounce(Duration::from_millis(1)), pool, ) diff --git a/crates/buzz-pubsub/src/run_presence.rs b/crates/buzz-pubsub/src/run_presence.rs new file mode 100644 index 00000000000..01730c6e3b8 --- /dev/null +++ b/crates/buzz-pubsub/src/run_presence.rs @@ -0,0 +1,127 @@ +//! Atomic per-run leases, shared across relay nodes. Disconnect never erases peers. +use crate::error::PubSubError; +use buzz_core::{run_presence::RunPresence, TenantContext}; +use deadpool_redis::Pool; +use nostr::PublicKey; + +fn key(ctx: &TenantContext, author: &PublicKey) -> String { + format!("buzz:{}:presence-runs:{}", ctx.community(), author.to_hex()) +} +// Bound active runs AND ordering tombstones. Retain offline fences beyond the +// maximum admissible timestamp window, without a process-local connection count. +const UPDATE: &str = r#" +local values = redis.call('HGETALL', KEYS[1]) +local now = tonumber(ARGV[3]) +for i = 1, #values, 2 do + local value = cjson.decode(values[i+1]) + if value.expires_at + 180 <= now then redis.call('HDEL', KEYS[1], values[i]) end +end +local old = redis.call('HGET', KEYS[1], ARGV[1]) +local incoming = cjson.decode(ARGV[2]) +if old then + old = cjson.decode(old) + if old.seq >= incoming.seq or old.status == 'offline' then return 0 end +elseif redis.call('HLEN', KEYS[1]) >= 32 then + return -1 +end +redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) +redis.call('EXPIRE', KEYS[1], 360) +return 1 +"#; + +/// Atomically accept a newer pulse; duplicates/reordered pulses do not renew TTL. +/// Returns false for obsolete pulses; saturation is a visible error, not eviction. +pub async fn update( + pool: &Pool, + ctx: &TenantContext, + author: &PublicKey, + pulse: &RunPresence, + now: u64, +) -> Result { + let mut conn = pool.get().await?; + let result: i64 = redis::Script::new(UPDATE) + .key(key(ctx, author)) + .arg(&pulse.run) + .arg(serde_json::to_string(pulse)?) + .arg(now) + .invoke_async(&mut conn) + .await?; + if result < 0 { + return Err(PubSubError::PresenceRunLimit); + } + Ok(result == 1) +} +/// Read live leases with their original deadlines. Errors are not empty/offline. +pub async fn active( + pool: &Pool, + ctx: &TenantContext, + author: &PublicKey, + now: u64, +) -> Result, PubSubError> { + let mut conn = pool.get().await?; + let values: Vec = redis::cmd("HVALS") + .arg(key(ctx, author)) + .query_async(&mut conn) + .await?; + let mut runs = Vec::new(); + for value in values { + let run: RunPresence = serde_json::from_str(&value)?; + if run.status != "offline" && run.expires_at > now { + runs.push(run); + } + } + runs.sort_by(|a, b| a.run.cmp(&b.run)); + Ok(runs) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::{run_presence::Location, CommunityId}; + #[tokio::test] + #[ignore = "requires Redis"] + async fn parallel_runs_ordering_offline_and_tenant_isolation() { + let pool = crate::test_util::make_test_pool(); + let ctx = + TenantContext::resolved(CommunityId::from_uuid(uuid::Uuid::new_v4()), "test.example"); + let other = TenantContext::resolved( + CommunityId::from_uuid(uuid::Uuid::new_v4()), + "other.example", + ); + let author = nostr::Keys::generate().public_key(); + let first = RunPresence { + run: "a".repeat(32), + seq: 0, + status: "online".into(), + expires_at: 280, + location: Some(Location { + host: author.to_hex(), + label: "One".into(), + }), + registration: None, + }; + let mut second = first.clone(); + second.run = "b".repeat(32); + assert!(update(&pool, &ctx, &author, &first, 100).await.unwrap()); + assert!(update(&pool, &ctx, &author, &second, 100).await.unwrap()); + assert!(!update(&pool, &ctx, &author, &first, 120).await.unwrap()); + assert_eq!(active(&pool, &ctx, &author, 120).await.unwrap().len(), 2); + assert!(active(&pool, &other, &author, 120) + .await + .unwrap() + .is_empty()); + let mut stop = first.clone(); + stop.seq = 1; + stop.status = "offline".into(); + assert!(update(&pool, &ctx, &author, &stop, 130).await.unwrap()); + assert!(!update(&pool, &ctx, &author, &first, 140).await.unwrap()); + let mut late = first.clone(); + late.seq = 2; + assert!(!update(&pool, &ctx, &author, &late, 150).await.unwrap()); + assert_eq!( + active(&pool, &ctx, &author, 150).await.unwrap(), + vec![second] + ); + assert!(active(&pool, &ctx, &author, 280).await.unwrap().is_empty()); + } +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 31fdb019bfc..e51670398d8 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1158,6 +1158,15 @@ async fn query_events_authed( .await?; if filters.iter().any(|f| f.search.is_some()) { + if filters + .iter() + .any(crate::handlers::history::explicitly_requests_hosts) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "error: host history search is unsupported", + )); + } if has_mixed_search_filters(&filters) { return Err(api_error( StatusCode::BAD_REQUEST, @@ -1476,7 +1485,13 @@ async fn query_events_authed( let db = state.db.clone(); let mut catchall_results = stream::iter(catchall_queries.into_iter().map(|(idx, query)| { let db = db.clone(); - async move { (idx, db.query_events_routed("bridge_query", &query).await) } + let filter = &filters[idx]; + async move { + ( + idx, + crate::handlers::history::query(&db, "bridge_query", filter, query).await, + ) + } })) .buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY); @@ -1506,6 +1521,10 @@ async fn query_events_authed( } } Err(e) => { + if crate::handlers::history::requires_primary(filter) { + tracing::warn!("Host-capable historical query failed: {e}"); + return Err(internal_error(e.wire_message())); + } return Err(internal_error(&format!("query error: {e}"))); } } @@ -2285,28 +2304,52 @@ async fn synthesize_presence( // Look up Redis. A lookup failure must surface as an error, not a // fake-empty success — otherwise a Redis outage is indistinguishable from // an authoritative all-offline snapshot to the consumer. - let presence_map = match pubsub.get_presence_bulk(tenant, &all_pubkeys).await { + let mut presence_map = match pubsub.get_presence_bulk(tenant, &all_pubkeys).await { Ok(map) => map, Err(e) => return Some(Err(internal_error(&format!("presence lookup: {e}")))), }; - - if presence_map.is_empty() { - return Some(Ok(Vec::new())); - } - - // Synthesize kind:20001 events signed by the relay. - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let mut events = Vec::with_capacity(presence_map.len()); - for (pubkey_hex, status) in &presence_map { - // Build a synthetic event: relay-signed, content = status, p-tag = subject. - // A build/sign failure here is an internal fault, not a "not a presence - // query" signal, so surface it as an error rather than falling through. - let tags = match nostr::Tag::parse(["p", pubkey_hex]) { - Ok(tag) => vec![tag], + let now = nostr::Timestamp::now().as_secs(); + let detailed = filters.iter().any(|f| { + f.kinds.as_ref().is_some_and(|ks| { + ks.iter() + .any(|k| k.as_u16() as u32 == KIND_PRESENCE_SNAPSHOT) + }) + }); + let mut events = Vec::new(); + for author in all_pubkeys { + let runs = match pubsub.active_presence_runs(tenant, &author, now).await { + Ok(runs) => runs, + Err(e) => return Some(Err(internal_error(&format!("presence runs: {e}")))), + }; + let pk = author.to_hex(); + let legacy = presence_map.remove(&pk); + let status = + if runs.iter().any(|r| r.status == "online") || legacy.as_deref() == Some("online") { + "online" + } else if !runs.is_empty() { + "away" + } else { + legacy.as_deref().unwrap_or("offline") + }; + if !detailed && status == "offline" { + continue; + } + let mut raw_tags = vec![vec!["p".to_string(), pk]]; + if detailed { + let payload = match serde_json::to_string(&runs) { + Ok(value) => value, + Err(e) => { + return Some(Err(internal_error(&format!("presence serialization: {e}")))) + } + }; + raw_tags.push(vec!["presence_runs".into(), "1".into(), payload]); + } + let tags = match raw_tags + .into_iter() + .map(nostr::Tag::parse) + .collect::, _>>() + { + Ok(tags) => tags, Err(e) => return Some(Err(internal_error(&format!("presence tag: {e}")))), }; let event = match nostr::EventBuilder::new( @@ -2320,9 +2363,9 @@ async fn synthesize_presence( Ok(event) => event, Err(e) => return Some(Err(internal_error(&format!("presence sign: {e}")))), }; - - if let Ok(v) = serde_json::to_value(&event) { - events.push(v); + match serde_json::to_value(&event) { + Ok(value) => events.push(value), + Err(e) => return Some(Err(internal_error(&format!("presence serialization: {e}")))), } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f3282..aad336fd07e 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -174,6 +174,28 @@ pub async fn filter_fanout_by_access( matches }; + // This is the shared fence for local ingest, direct sends and Redis fan-out. + // A subscription (including a kindless known-id lookup) is not read authority. + let matches = + if buzz_core::kind::RESULT_GATED_KINDS.contains(&event_kind_u32(&stored_event.event)) { + matches + .into_iter() + .filter(|(conn_id, _)| { + state + .conn_manager + .pubkey_for_conn(*conn_id) + .is_some_and(|pk| { + buzz_core::filter::reader_authorized_for_event( + &stored_event.event, + &hex::encode(pk), + ) + }) + }) + .collect() + } else { + matches + }; + let Some(channel_id) = stored_event.channel_id else { return matches; }; @@ -234,10 +256,8 @@ pub async fn filter_fanout_by_access( /// /// All relay-local live fan-out routes through here. The two exceptions are /// `dispatch_persistent_event` (persistent ingest) and `fan_out_pubsub_event` -/// (Redis cross-node), which call `filter_fanout_by_access` inline: the former -/// layers an additional per-recipient DM-visibility-owner gate on top, the -/// latter skips local echoes — both are equivalent to this helper plus their -/// own extra step. +/// (Redis cross-node), which call `filter_fanout_by_access` inline. The latter +/// also skips local echoes; neither bypasses the shared private-event gates. pub(crate) async fn fan_out_event_to_local_subscribers( state: &AppState, community_id: CommunityId, @@ -454,40 +474,10 @@ async fn dispatch_persistent_event_inner( return 0; } }; - // For viewer-private events (kind:30622 DM visibility, kind:44200 agent turn - // metrics), live fan-out must reach only the owner — a kindless `ids:[…]` - // subscription can otherwise match it. Pull paths (HTTP /query, WS historical) - // are gated separately by reader_authorized_for_event. - let owner_only_kind = kind_u32 == buzz_core::kind::KIND_DM_VISIBILITY - || kind_u32 == buzz_core::kind::KIND_AGENT_TURN_METRIC; - let private_event_owner: Option = owner_only_kind - .then(|| { - let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); - stored_event - .event - .tags - .filter(nostr::TagKind::SingleLetter(p)) - .find_map(|t| t.content().map(|s| s.to_string())) - }) - .flatten(); - // Author-only delivery gating (NIP-ER reminders) is enforced centrally in - // filter_fanout_by_access, applied to `matches` above before this loop. The - // DM visibility owner gate is an additional delivery fence, so build shared - // frames only after applying it to the already access-filtered recipient set. + // All private-event gates have already run in filter_fanout_by_access. let recipients: Vec<_> = matches .iter() - .filter_map(|(target_conn_id, sub_id)| { - if let Some(ref owner_hex) = private_event_owner { - let is_owner = state - .conn_manager - .pubkey_for(*target_conn_id) - .is_some_and(|pk| hex::encode(pk) == *owner_hex); - if !is_owner { - return None; - } - } - Some((*target_conn_id, sub_id.as_str())) - }) + .map(|(conn_id, sub_id)| (*conn_id, sub_id.as_str())) .collect(); let frames = fanout_frame_cache(recipients.iter().map(|(_, sub_id)| *sub_id), &event_json); let drop_count = send_fanout_frames(state, recipients, &frames); @@ -653,11 +643,36 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc(&raw) - .ok() - .and_then(|v| v.get("status")?.as_str().map(String::from)) - .unwrap_or(raw) - } else if raw.len() > 128 { - let mut end = 128; - while !raw.is_char_boundary(end) { - end -= 1; + if let Some(pulse) = + buzz_core::run_presence::parse_run(&event, nostr::Timestamp::now().as_secs())? + { + // No process-local disconnect cleanup and no shared pubkey slot: + // one run stopping must not erase another node's live placement. + if !state + .pubsub + .update_run_presence( + &conn.tenant, + &event.pubkey, + &pulse, + nostr::Timestamp::now().as_secs(), + ) + .await + .map_err(|_| "error: presence store unavailable")? + { + return Ok(()); // stale/duplicate updates are never fanned out } - raw[..end].to_string() } else { - raw - }; + // Accept both bare strings ("online") and legacy JSON ({"status":"online"}). + let raw = event.content.to_string(); + let status = if raw.starts_with('{') { + serde_json::from_str::(&raw) + .ok() + .and_then(|v| v.get("status")?.as_str().map(String::from)) + .unwrap_or(raw) + } else if raw.len() > 128 { + let mut end = 128; + while !raw.is_char_boundary(end) { + end -= 1; + } + raw[..end].to_string() + } else { + raw + }; - if status == "offline" { - let _ = state - .pubsub - .clear_presence(&conn.tenant, &auth_pubkey) - .await; - } else { - let _ = state - .pubsub - .set_presence(&conn.tenant, &auth_pubkey, &status) - .await; + if status == "offline" { + let _ = state + .pubsub + .clear_presence(&conn.tenant, &auth_pubkey) + .await; + } else { + let _ = state + .pubsub + .set_presence(&conn.tenant, &auth_pubkey, &status) + .await; + } } - // Presence is a channel-less ephemeral event. After updating Redis // presence state, let it fall through to the shared global ephemeral // publish/fan-out path below so other relay nodes receive the live delta. @@ -1165,6 +1199,8 @@ fn single_tag_content<'a>(event: &'a Event, tag_name: &str) -> Result<&'a str, S #[cfg(test)] mod tests { + mod host_transport_tests; + use std::collections::HashMap; use std::sync::atomic::AtomicU8; use std::sync::Arc; @@ -1659,6 +1695,93 @@ mod tests { ); } + #[tokio::test] + async fn private_hosts_only_reach_owner_across_all_live_delivery_paths() { + let owner = Keys::generate(); + let host = Keys::generate(); + let outsider = Keys::generate(); + let reg = buzz_core::host::registration(&owner, host.public_key(), 100).unwrap(); + let rep = buzz_core::host::report( + &host, + ®, + &buzz_core::host::Report { + v: 1, + name: "private machine".into(), + os: "macos".into(), + arch: "aarch64".into(), + launcher_version: "test".into(), + runtimes: vec![], + accepts_start: false, + provisioned: vec![], + }, + 101, + ) + .unwrap(); + for event in [reg, rep] { + for path in ["pubsub", "direct", "ingest"] { + let state = test_state().await; + let filter = Filter::new().id(event.id); // no kind or #p gate + let mut receivers = Vec::new(); + for (name, key) in [ + ("owner", Some(&owner)), + ("outsider", Some(&outsider)), + ("host", Some(&host)), + ("anonymous", None), + ] { + let (_, rx) = register_global_sub( + &state, + name, + filter.clone(), + key.map(|k| k.public_key().to_bytes().to_vec()), + ); + receivers.push((name, rx)); + } + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let stored = buzz_core::StoredEvent::new(event.clone(), None); + match path { + "pubsub" => { + fan_out_pubsub_event( + &state, + ChannelEvent { + community_id: community, + topic: EventTopic::Global, + event: event.clone(), + }, + ) + .await + } + "direct" => { + super::super::fan_out_event_to_local_subscribers( + &state, community, &stored, + ) + .await + } + _ => { + super::super::dispatch_persistent_event_inner( + &buzz_core::tenant::TenantContext::resolved(community, "test"), + &state, + &stored, + buzz_core::kind::KIND_HOST, + &owner.public_key().to_hex(), + false, + None, + ) + .await; + } + } + for (name, mut rx) in receivers { + if name == "owner" { + assert_eq!(event_from_ws_message(rx.try_recv().unwrap()).id, event.id); + } + assert!( + rx.try_recv().is_err(), + "{path}: unexpected delivery to {name}" + ); + } + } + } + } + async fn redis_url_if_available() -> Option { let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); diff --git a/crates/buzz-relay/src/handlers/event/tests/host_transport_tests.rs b/crates/buzz-relay/src/handlers/event/tests/host_transport_tests.rs new file mode 100644 index 00000000000..d07d5a73722 --- /dev/null +++ b/crates/buzz-relay/src/handlers/event/tests/host_transport_tests.rs @@ -0,0 +1,468 @@ +//! Parse EVENT frames and exercise the WS handler, shared ingest, and primary DB. +use super::*; +use axum::extract::ws::Message; +use buzz_auth::{AuthContext, AuthMethod, Scope}; +use buzz_core::{host, tenant::TenantContext}; +use nostr::{Event, Timestamp}; +use serde_json::{json, Value}; + +fn connection( + tenant: TenantContext, + keys: &Keys, +) -> ( + Arc, + mpsc::Receiver, +) { + let (send_tx, rx) = mpsc::channel(32); + let (ctrl_tx, _) = mpsc::channel(8); + ( + Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant, + remote_addr: "127.0.0.1:1".parse().unwrap(), + auth_state: RwLock::new(crate::connection::AuthState::Authenticated(AuthContext { + pubkey: keys.public_key(), + scopes: vec![Scope::UsersWrite, Scope::MessagesWrite], + channel_ids: None, + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: None, + })), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + cancel: CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }), + rx, + ) +} + +async fn publish( + state: &Arc, + conn: &Arc, + rx: &mut mpsc::Receiver, + event: &Event, +) -> Value { + let crate::protocol::ClientMessage::Event(parsed) = + crate::protocol::ClientMessage::parse(&json!(["EVENT", event]).to_string()).unwrap() + else { + panic!("EVENT frame"); + }; + super::super::handle_event(parsed, conn.clone(), state.clone()).await; + let Message::Text(frame) = rx.try_recv().expect("handler must send ACK") else { + panic!("text ACK"); + }; + let ack: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(ack[0], "OK"); + assert_eq!(ack[1], event.id.to_hex()); + assert!(rx.try_recv().is_err()); + ack +} + +fn resign(event: &Event, keys: &Keys, tags: Vec, kind: Kind) -> Event { + EventBuilder::new(kind, event.content.clone()) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +#[tokio::test] +#[ignore = "requires isolated MULTIVERSE_TEST_DATABASE_URL and REDIS_URL; no skip fallback"] +async fn host_report_owner_ws_handler_ack_and_strict_negatives() { + let url = std::env::var("MULTIVERSE_TEST_DATABASE_URL").expect("isolated PG required"); + assert_eq!(std::env::var("DATABASE_URL").unwrap(), url); + let redis = std::env::var("REDIS_URL").expect("isolated Redis required"); + let state = super::fanout_access::test_state_with_redis_url(&redis).await; + let community = state + .db + .ensure_configured_community(&format!("host-event-{}.test", Uuid::new_v4())) + .await + .unwrap(); + let tenant = TenantContext::resolved(community.id, community.host); + let owner = Keys::generate(); + let host = Keys::generate(); + let outsider = Keys::generate(); + let (conn, mut rx) = connection(tenant.clone(), &owner); + let now = Timestamp::now().as_secs(); + let reg = host::registration(&owner, host.public_key(), now).unwrap(); + let rep = host::report( + &host, + ®, + &host::Report { + v: 1, + name: "synthetic".into(), + os: "test".into(), + arch: "test".into(), + launcher_version: "test".into(), + runtimes: vec![], + accepts_start: false, + provisioned: vec![], + }, + now, + ) + .unwrap(); + for event in [®, &rep] { + let ack = publish(&state, &conn, &mut rx, event).await; + assert_eq!(ack[2], true, "{ack}"); + let stored = state + .db + .get_event_by_id(tenant.community(), &event.id.to_bytes()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.event, *event, "ACK must follow writer commit"); + } + // Authorization precedes duplicate detection: even an accepted event is not + // portable to an outsider, a host-authenticated socket, or a scoped token. + for keys in [&outsider, &host] { + let (foreign, mut foreign_rx) = connection(tenant.clone(), keys); + let ack = publish(&state, &foreign, &mut foreign_rx, &rep).await; + assert_eq!(ack[2], false, "{ack}"); + assert!(ack[3] + .as_str() + .unwrap() + .contains("owner's global connection")); + } + let (scoped, mut scoped_rx) = connection(tenant.clone(), &owner); + if let crate::connection::AuthState::Authenticated(ctx) = &mut *scoped.auth_state.write().await + { + ctx.channel_ids = Some(vec![]); + } + assert_eq!( + publish(&state, &scoped, &mut scoped_rx, &rep).await[2], + false + ); + + let tags: Vec = rep.tags.iter().cloned().collect(); + let replace = |key: &str, value: String| -> Vec { + tags.iter() + .map(|t| { + if t.as_slice()[0] == key { + Tag::parse([key.to_owned(), value.clone()]).unwrap() + } else { + t.clone() + } + }) + .collect() + }; + let mut duplicate_owner = tags.clone(); + duplicate_owner.push(Tag::parse(["p", &owner.public_key().to_hex()]).unwrap()); + let negatives = [ + ( + resign(&rep, &outsider, tags.clone(), rep.kind), + "signer does not match", + ), + ( + resign( + &rep, + &outsider, + replace("x", outsider.public_key().to_hex()), + rep.kind, + ), + "binding mismatch", + ), + ( + resign( + &rep, + &host, + replace("L", "forged.namespace".into()), + rep.kind, + ), + "namespace", + ), + ( + resign(&rep, &host, replace("e", "00".repeat(32)), rep.kind), + "registration not found", + ), + ( + resign( + &rep, + &host, + replace("valid_until", (now + 1000).to_string()), + rep.kind, + ), + "lifetime", + ), + ( + resign(&rep, &host, duplicate_owner, rep.kind), + "cardinality", + ), + ( + resign(&rep, &host, tags.clone(), Kind::Metadata), + "pubkey does not match", + ), + ( + resign( + &rep, + &host, + tags.clone(), + Kind::Custom(KIND_PRESENCE_UPDATE as u16), + ), + "pubkey does not match", + ), + ( + resign( + &rep, + &host, + tags, + Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), + ), + "pubkey does not match", + ), + ]; + for (event, reason) in negatives { + let ack = publish(&state, &conn, &mut rx, &event).await; + assert_eq!(ack[2], false, "{ack}"); + assert!( + ack[3].as_str().unwrap().contains(reason), + "{ack}: expected {reason}" + ); + assert!(state + .db + .get_event_by_id(tenant.community(), &event.id.to_bytes()) + .await + .unwrap() + .is_none()); + } + let (anonymous, mut anon_rx) = connection(tenant, &owner); + *anonymous.auth_state.write().await = crate::connection::AuthState::Pending { + challenge: "test".into(), + }; + assert_eq!( + publish(&state, &anonymous, &mut anon_rx, &rep).await[2], + false + ); +} + +#[tokio::test] +#[ignore = "requires isolated MULTIVERSE_TEST_DATABASE_URL and REDIS_URL; no skip fallback"] +async fn host_and_agent_runs_require_authority_and_preserve_other_placements() { + let url = std::env::var("MULTIVERSE_TEST_DATABASE_URL").expect("isolated PG required"); + assert_eq!(std::env::var("DATABASE_URL").unwrap(), url); + let redis = std::env::var("REDIS_URL").expect("isolated Redis required"); + let state = super::fanout_access::test_state_with_redis_url(&redis).await; + let community = state + .db + .ensure_configured_community(&format!("runs-{}.test", Uuid::new_v4())) + .await + .unwrap(); + let tenant = TenantContext::resolved(community.id, community.host); + let other_community = state + .db + .ensure_configured_community(&format!("runs-other-{}.test", Uuid::new_v4())) + .await + .unwrap(); + let other_tenant = TenantContext::resolved(other_community.id, other_community.host); + let owner = Keys::generate(); + let host = Keys::generate(); + let agent = Keys::generate(); + let outsider = Keys::generate(); + let (conn, mut rx) = connection(tenant.clone(), &owner); + let now = Timestamp::now().as_secs(); + let reg = host::registration(&owner, host.public_key(), now).unwrap(); + assert_eq!(publish(&state, &conn, &mut rx, ®).await[2], true); + let host_pulse = buzz_core::run_presence::pulse( + &host, + &"a".repeat(32), + 0, + "online", + None, + Some(®.id.to_hex()), + now, + ) + .unwrap(); + assert_eq!(publish(&state, &conn, &mut rx, &host_pulse).await[2], true); + for (scope, signer) in [ + (tenant.clone(), &host), + (tenant.clone(), &outsider), + (other_tenant.clone(), &owner), + ] { + let (foreign, mut foreign_rx) = connection(scope, signer); + assert_eq!( + publish(&state, &foreign, &mut foreign_rx, &host_pulse).await[2], + false + ); + } + let (scoped, mut scoped_rx) = connection(tenant.clone(), &owner); + if let crate::connection::AuthState::Authenticated(auth) = &mut *scoped.auth_state.write().await + { + auth.channel_ids = Some(vec![]); + } + assert_eq!( + publish(&state, &scoped, &mut scoped_rx, &host_pulse).await[2], + false + ); + let mut tampered = host_pulse.clone(); + tampered.content = "offline".into(); + assert_eq!(publish(&state, &conn, &mut rx, &tampered).await[2], false); + assert_eq!( + state + .pubsub + .active_presence_runs(&tenant, &host.public_key(), now) + .await + .unwrap() + .len(), + 1 + ); + assert!(state + .pubsub + .active_presence_runs(&other_tenant, &host.public_key(), now) + .await + .unwrap() + .is_empty()); + + let (agent_conn, mut agent_rx) = connection(tenant.clone(), &agent); + let location = buzz_core::run_presence::Location { + host: host.public_key().to_hex(), + label: "Workshop".into(), + }; + let pulse = |run: &str, seq, status| { + buzz_core::run_presence::pulse(&agent, run, seq, status, Some(&location), None, now) + .unwrap() + }; + let first = pulse(&"b".repeat(32), 0, "online"); + let second = pulse(&"c".repeat(32), 0, "online"); + // Possessing the owner connection never permits impersonating an agent pulse. + assert_eq!(publish(&state, &conn, &mut rx, &first).await[2], false); + for event in [&first, &second] { + assert_eq!( + publish(&state, &agent_conn, &mut agent_rx, event).await[2], + true + ); + } + let stop = pulse(&"b".repeat(32), 1, "offline"); + assert_eq!( + publish(&state, &agent_conn, &mut agent_rx, &stop).await[2], + true + ); + // Delayed pulses are ACKed but cannot resurrect a stopped generation. + assert_eq!( + publish(&state, &agent_conn, &mut agent_rx, &first).await[2], + true + ); + let runs = state + .pubsub + .active_presence_runs(&tenant, &agent.public_key(), now) + .await + .unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].run, "c".repeat(32)); + assert_eq!(runs[0].location, Some(location)); + assert!(state + .pubsub + .active_presence_runs(&tenant, &agent.public_key(), now + 180) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +#[ignore = "requires isolated MULTIVERSE_TEST_DATABASE_URL and REDIS_URL; no skip fallback"] +async fn start_command_receipt_signed_admission_revocation_tenant_and_fts() { + use buzz_core::host_execution::{self, Action, Command, Outcome, Receipt}; + let url = std::env::var("MULTIVERSE_TEST_DATABASE_URL").expect("isolated PG required"); + assert_eq!(std::env::var("DATABASE_URL").unwrap(), url); + let state = + super::fanout_access::test_state_with_redis_url(&std::env::var("REDIS_URL").unwrap()).await; + let pool = sqlx::PgPool::connect(&url).await.unwrap(); + let mut tenants = Vec::new(); + for _ in 0..2 { + let community = state + .db + .ensure_configured_community(&format!("start-{}.test", Uuid::new_v4())) + .await + .unwrap(); + tenants.push(TenantContext::resolved(community.id, community.host)); + } + let owner = Keys::generate(); + let host = Keys::generate(); + let outsider = Keys::generate(); + let (conn, mut rx) = connection(tenants[0].clone(), &owner); + let now = Timestamp::now().as_secs(); + let reg = host::registration(&owner, host.public_key(), now).unwrap(); + assert_eq!(publish(&state, &conn, &mut rx, ®).await[2], true); + let request = Command { + v: 1, + operation: "ab".repeat(16), + relay: "wss://start.test".into(), + agent: Keys::generate().public_key().to_hex(), + expires_at: now + 300, + action: Action::Start { + runtime: "goose".into(), + revision: "cd".repeat(32), + }, + }; + let command = host_execution::command(&owner, ®, &request, now).unwrap(); + let receipt = host_execution::receipt( + &host, + ®, + &Receipt { + v: 1, + command: command.id.to_hex(), + run: request.run().into(), + request, + outcome: Outcome::Spawned, + observed_at: now, + }, + now, + ) + .unwrap(); + for event in [&command, &receipt] { + for _ in 0..2 { + // exact resend after lost ACK is admitted, never a second event + let ack = publish(&state, &conn, &mut rx, event).await; + assert_eq!(ack[2], true, "{ack}"); + } + let indexed: bool = sqlx::query_scalar( + "SELECT search_tsv IS NOT NULL FROM events WHERE community_id=$1 AND id=$2", + ) + .bind(tenants[0].community().as_uuid()) + .bind(event.id.to_bytes().to_vec()) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + !indexed, + "private command/receipt ciphertext is excluded from FTS" + ); + for (tenant, signer) in [ + (tenants[0].clone(), &outsider), + (tenants[0].clone(), &host), + (tenants[1].clone(), &owner), + ] { + let (foreign, mut foreign_rx) = connection(tenant, signer); + assert_eq!( + publish(&state, &foreign, &mut foreign_rx, event).await[2], + false + ); + } + let (scoped, mut scoped_rx) = connection(tenants[0].clone(), &owner); + if let crate::connection::AuthState::Authenticated(ctx) = + &mut *scoped.auth_state.write().await + { + ctx.channel_ids = Some(vec![]); + } + assert_eq!( + publish(&state, &scoped, &mut scoped_rx, event).await[2], + false + ); + let forged = resign( + event, + &outsider, + event.tags.iter().cloned().collect(), + event.kind, + ); + assert_eq!(publish(&state, &conn, &mut rx, &forged).await[2], false); + } + // Delete only this fixture's exact registration. Authorization must run + // before duplicate detection, including replay of already stored ciphertext. + assert!( + buzz_db::event::soft_delete_event(&pool, tenants[0].community(), ®.id.to_bytes()) + .await + .unwrap() + ); + for event in [&command, &receipt] { + assert_eq!(publish(&state, &conn, &mut rx, event).await[2], false); + } +} diff --git a/crates/buzz-relay/src/handlers/history.rs b/crates/buzz-relay/src/handlers/history.rs new file mode 100644 index 00000000000..2b40ef2cc1a --- /dev/null +++ b/crates/buzz-relay/src/handlers/history.rs @@ -0,0 +1,76 @@ +//! Historical reads used by WS REQ and the HTTP bridge. +//! +//! Host reconciliation is a write decision, not a bounded-stale display read. +use buzz_core::{kind::KIND_HOST, StoredEvent}; +use buzz_db::{Db, EventQuery}; +use nostr::Filter; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum HistoryError { + #[error("database error: {0}")] + Database(#[from] buzz_db::DbError), +} + +impl HistoryError { + // Never put database diagnostics or private query metadata on the wire. + pub(crate) fn wire_message(&self) -> &'static str { + match self { + Self::Database(_) => "error: database error", + } + } +} + +pub(crate) fn explicitly_requests_hosts(filter: &Filter) -> bool { + filter.kinds.as_ref().is_some_and(|kinds| { + kinds.iter().any(|kind| { + matches!( + u32::from(kind.as_u16()), + KIND_HOST | buzz_core::kind::KIND_HOST_COMMAND | buzz_core::kind::KIND_HOST_RECEIPT + ) + }) + }) +} + +pub(crate) fn requires_primary(filter: &Filter) -> bool { + // Kindless known-ID reads can also return a host. Do not let that spelling + // turn a reconciliation read into a replica read. Explicit unrelated kinds + // keep their existing routing. Kind 50000 currently admits only buzz.host.v1. + filter.kinds.is_none() || explicitly_requests_hosts(filter) +} + +pub(crate) async fn query( + db: &Db, + path: &'static str, + filter: &Filter, + mut params: EventQuery, +) -> Result, HistoryError> { + if !requires_primary(filter) { + return Ok(db.query_events_routed(path, ¶ms).await?); + } + // event_mentions is a best-effort index written AFTER the event commit. + // Even on the primary that join can transiently (or permanently, on index + // write failure) hide an existing registration. Use the authoritative event + // tags for host-capable reads. All other per-result gates still run. + // The bridge's unrelated buzz-channel extension also uses custom_tag; + // retain it rather than changing the semantics of a kindless query. + if params.custom_tag.is_none() { + if let Some(owner) = params.p_tag_hex.take() { + params.custom_tag = Some(("p".into(), owner)); + } + } + if explicitly_requests_hosts(filter) { + // Push every generic tag into the authoritative query. A short page + // now proves exhaustion, even when thousands of unrelated profiles or + // other owners' records would otherwise consume the candidate LIMIT. + params.p_tag_hex = None; + params.exact_tags = filter + .generic_tags + .iter() + .map(|(name, values)| (name.to_string(), values.iter().cloned().collect())) + .collect(); + } + Ok(db.query_events(¶ms).await?) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-relay/src/handlers/history/tests.rs b/crates/buzz-relay/src/handlers/history/tests.rs new file mode 100644 index 00000000000..6a573244803 --- /dev/null +++ b/crates/buzz-relay/src/handlers/history/tests.rs @@ -0,0 +1,299 @@ +//! Drive parsed REQs through the real handler and inspect outbound WS frames. +use std::collections::HashMap; +use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ws::Message; +use buzz_auth::{AuthContext, AuthMethod}; +use buzz_core::tenant::{CommunityId, TenantContext}; +use buzz_db::Db; +use nostr::Keys; +use serde_json::{json, Value}; +use sqlx::postgres::PgPoolOptions; +use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::connection::{AuthState, ConnectionState}; +use crate::protocol::ClientMessage; +use crate::state::AppState; + +async fn state(db: Db, pool: sqlx::PgPool) -> Arc { + state_with_redis(db, pool, "redis://127.0.0.1:1".into()).await +} + +async fn state_with_redis(db: Db, pool: sqlx::PgPool, redis_url: String) -> Arc { + let mut config = crate::config::Config::from_env().unwrap(); + config.require_relay_membership = false; + config.redis_url = redis_url; + config.require_auth_token = true; + let redis = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis.clone()) + .await + .unwrap(), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool); + let workflow = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + Default::default(), + )); + let media = buzz_media::MediaStorage::new(&config.media).unwrap(); + let (state, _) = AppState::new( + config, + db, + redis, + audit, + pubsub, + auth, + search, + workflow, + Keys::generate(), + media, + ); + Arc::new(state) +} + +fn connection( + state: &AppState, + tenant: TenantContext, + keys: &Keys, +) -> (Arc, mpsc::Receiver) { + let (send_tx, rx) = mpsc::channel(2048); + let (ctrl_tx, _) = mpsc::channel(8); + state.accessible_channels_cache.insert( + (tenant.community(), keys.public_key().to_bytes().to_vec()), + vec![], + ); + ( + Arc::new(ConnectionState { + conn_id: Uuid::new_v4(), + tenant, + remote_addr: "127.0.0.1:1".parse().unwrap(), + auth_state: RwLock::new(AuthState::Authenticated(AuthContext { + pubkey: keys.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: None, + })), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + cancel: CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }), + rx, + ) +} + +async fn req( + state: &Arc, + conn: &Arc, + rx: &mut mpsc::Receiver, + filters: Vec, +) -> Vec { + let mut wire = vec![json!("REQ"), json!("host-history")]; + wire.extend(filters); + let ClientMessage::Req { sub_id, filters } = + ClientMessage::parse(&Value::Array(wire).to_string()).unwrap() + else { + panic!("REQ") + }; + crate::handlers::req::handle_req(sub_id, filters, conn.clone(), state.clone()).await; + let mut frames = vec![]; + while let Ok(frame) = rx.try_recv() { + let Message::Text(text) = frame else { + panic!("text frame") + }; + frames.push(serde_json::from_str(&text).unwrap()); + } + frames +} + +fn host_filter(keys: &Keys, label: &str) -> Value { + json!({"kinds":[50000], "authors":[keys.public_key().to_hex()], "#p":[keys.public_key().to_hex()], "#L":["buzz.host.v1"], "#l":[label], "limit":1000}) +} + +/// A listening TCP endpoint counts attempted replica checkouts. The fence and +/// bounded-read budget are open; a control REQ proves routing reaches it. No PG +/// or Redis service is required and no real configuration credentials are used. +#[tokio::test] +async fn host_req_failure_is_closed_and_bypasses_eligible_replica() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + let count = attempts.clone(); + let server = tokio::spawn(async move { + while let Ok((socket, _)) = listener.accept().await { + count.fetch_add(1, Ordering::SeqCst); + drop(socket); + } + }); + let writer = PgPoolOptions::new() + .connect_lazy("postgres://test:test@127.0.0.1:1/test") + .unwrap(); + writer.close().await; // Deterministic query failure, not access-cache failure. + let reader = PgPoolOptions::new() + .acquire_timeout(Duration::from_millis(100)) + .connect_lazy(&format!( + "postgres://test:test@{address}/test?sslmode=disable" + )) + .unwrap(); + let mut db = Db::from_pools(writer.clone(), reader); + db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(60))); + db.fence().force_open_for_tests(chrono::Utc::now()); + let state = state(db, writer).await; + let owner = Keys::generate(); + let tenant = + TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "host-history.test"); + let (conn, mut rx) = connection(&state, tenant, &owner); + for filters in [ + vec![host_filter(&owner, "registration")], + vec![host_filter(&owner, "report")], + vec![json!({"ids":["aa".repeat(32)]})], + // Failure in another filter must not turn a host-containing REQ into + // partial success. The whole multi-filter history is one read outcome. + vec![json!({"kinds":[0]}), host_filter(&owner, "registration")], + ] { + let before = attempts.load(Ordering::SeqCst); + let mixed = filters.len() > 1; + let frames = req(&state, &conn, &mut rx, filters).await; + assert_eq!( + frames, + vec![json!(["CLOSED", "host-history", "error: database error"])] + ); + assert!(conn.subscriptions.lock().await.is_empty()); + assert_eq!(state.sub_registry.total_subscriptions(), 0); + if !mixed { + assert_eq!( + attempts.load(Ordering::SeqCst), + before, + "host reads must never attempt a replica checkout" + ); + } + } + let before = attempts.load(Ordering::SeqCst); + let frames = req(&state, &conn, &mut rx, vec![json!({"kinds":[0]})]).await; + assert_eq!(frames, vec![json!(["EOSE", "host-history"])]); + assert!( + attempts.load(Ordering::SeqCst) > before, + "negative control must attempt routed history" + ); + assert_eq!( + state.sub_registry.total_subscriptions(), + 1, + "legacy unrelated error behavior retained" + ); + server.abort(); +} + +#[tokio::test] +async fn host_req_authorization_and_search_fail_before_history() { + let pool = PgPoolOptions::new() + .connect_lazy("postgres://test:test@127.0.0.1:1/test") + .unwrap(); + pool.close().await; + let state = state(Db::from_pool(pool.clone()), pool).await; + let owner = Keys::generate(); + let other = Keys::generate(); + let tenant = + TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "host-history.test"); + let (conn, mut rx) = connection(&state, tenant, &other); + let frames = req( + &state, + &conn, + &mut rx, + vec![host_filter(&owner, "registration")], + ) + .await; + assert_eq!(frames[0][0], "CLOSED"); + assert!(frames[0][2].as_str().unwrap().starts_with("restricted:")); + let mut search = host_filter(&other, "registration"); + search["search"] = json!("test"); + assert_eq!( + req(&state, &conn, &mut rx, vec![search]).await, + vec![json!([ + "CLOSED", + "host-history", + "error: host history search is unsupported" + ])] + ); + assert_eq!(state.sub_registry.total_subscriptions(), 0); +} + +/// Opt-in transport integration against an isolated, migrated database. Does +/// not silently skip or use the configured/shared relay database. +#[tokio::test] +#[ignore = "requires isolated migrated Postgres: MULTIVERSE_TEST_DATABASE_URL"] +async fn host_req_primary_exact_tags_precede_limit_and_private_results_are_safe() { + let url = + std::env::var("MULTIVERSE_TEST_DATABASE_URL").expect("isolated database URL required"); + let pool = sqlx::PgPool::connect(&url).await.unwrap(); + let db = Db::from_pool(pool.clone()); + let host_name = format!("host-history-{}.test", Uuid::new_v4()); + let community = db.ensure_configured_community(&host_name).await.unwrap(); + let tenant = TenantContext::resolved(community.id, host_name); + let owner = Keys::generate(); + let registration = buzz_core::host::registration( + &owner, + Keys::generate().public_key(), + nostr::Timestamp::now().as_secs(), + ) + .unwrap(); + // Deliberately omit event_mentions. It is not authoritative registration state. + buzz_db::event::insert_event(&pool, tenant.community(), ®istration, None) + .await + .unwrap(); + let state = state(db, pool.clone()).await; + let (conn, mut rx) = connection(&state, tenant.clone(), &owner); + let frames = req( + &state, + &conn, + &mut rx, + vec![host_filter(&owner, "registration")], + ) + .await; + assert_eq!(frames.len(), 2); + assert_eq!(frames[0][2]["id"], registration.id.to_hex()); + assert_eq!(frames[1], json!(["EOSE", "host-history"])); + + // Unrelated registrations cannot consume a report page before LIMIT. + let mut capped = host_filter(&owner, "report"); + capped["limit"] = json!(1); + assert_eq!( + req(&state, &conn, &mut rx, vec![capped]).await, + vec![json!(["EOSE", "host-history"])] + ); + assert_eq!(state.sub_registry.total_subscriptions(), 1); + + // Known-ID requests still apply owner-only visibility on every returned row. + for keys in [&owner, &Keys::generate()] { + let (conn, mut rx) = connection(&state, tenant.clone(), keys); + let frames = req( + &state, + &conn, + &mut rx, + vec![json!({"ids":[registration.id.to_hex()], "limit":1})], + ) + .await; + if keys.public_key() == owner.public_key() { + assert_eq!(frames.len(), 2); + assert_eq!(frames[0][2]["id"], registration.id.to_hex()); + } else { + assert_eq!(frames, vec![json!(["EOSE", "host-history"])]); + } + } + // This fixture uses a unique community; leave service lifecycle to its owner. + pool.close().await; +} + +mod paging; + +mod mixed_tags; diff --git a/crates/buzz-relay/src/handlers/history/tests/mixed_tags.rs b/crates/buzz-relay/src/handlers/history/tests/mixed_tags.rs new file mode 100644 index 00000000000..e003b548f40 --- /dev/null +++ b/crates/buzz-relay/src/handlers/history/tests/mixed_tags.rs @@ -0,0 +1,95 @@ +//! The shared SQL predicate must agree with the core matcher, even when a +//! host-capable filter also requests derived-channel reactions/deletions. +use super::*; +use buzz_core::filter::filters_match; +use buzz_db::EventQuery; +use nostr::{EventBuilder, Filter, Kind, Tag}; + +#[tokio::test] +#[ignore = "requires isolated migrated Postgres: MULTIVERSE_TEST_DATABASE_URL"] +async fn mixed_host_h_filter_preserves_derived_channels_and_explicit_tag_authority() { + let url = std::env::var("MULTIVERSE_TEST_DATABASE_URL").expect("isolated DB required"); + let pool = sqlx::PgPool::connect(&url).await.unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("mixed-tags-{}.test", Uuid::new_v4())) + .await + .unwrap(); + let owner = Keys::generate(); + let author = Keys::generate(); + let channel = Uuid::new_v4(); + let other_channel = Uuid::new_v4().to_string(); + let channel_text = channel.to_string(); + let owner_text = owner.public_key().to_hex(); + let mut expected = vec![]; + for (index, h_tags) in [ + vec![], + vec![vec!["h", channel_text.as_str()]], + vec![vec!["h", other_channel.as_str()]], + vec![vec!["h"]], + vec![vec!["h", other_channel.as_str(), channel_text.as_str()]], + ] + .into_iter() + .enumerate() + { + let mut tags = vec![Tag::parse(["p", &owner_text]).unwrap()]; + tags.extend(h_tags.into_iter().map(|t| Tag::parse(t).unwrap())); + let event = EventBuilder::new(Kind::Reaction, index.to_string()) + .tags(tags) + .sign_with_keys(&author) + .unwrap(); + buzz_db::event::insert_event(&pool, community.id, &event, Some(channel)) + .await + .unwrap(); + if index < 2 { + expected.push(event.id); + } + } + for kinds in [json!([7]), json!([7, 50000])] { + for values in [ + json!([channel_text]), + json!([Uuid::new_v4().to_string(), channel_text]), + json!([]), + ] { + let filter: Filter = serde_json::from_value(json!({ + "kinds": kinds, "#h": values, "#p": [owner_text], "limit": 1000 + })) + .unwrap(); + let mut params = EventQuery::for_community(community.id); + params.kinds = Some(if kinds == json!([7]) { + vec![7] + } else { + vec![7, 50000] + }); + params.exact_tags = filter + .generic_tags + .iter() + .map(|(k, v)| (k.to_string(), v.iter().cloned().collect())) + .collect(); + // No event_mentions index: exact p predicates use primary tags. + let rows = + crate::handlers::history::query(&db, "mixed-tags-test", &filter, params.clone()) + .await + .unwrap(); + let count = buzz_db::event::count_events(&pool, ¶ms).await.unwrap(); + assert_eq!( + count as usize, + rows.len(), + "COUNT shares the query predicate" + ); + assert!(rows + .iter() + .all(|row| filters_match(std::slice::from_ref(&filter), row))); + let mut actual: Vec<_> = rows.iter().map(|row| row.event.id).collect(); + actual.sort(); + let mut want = if values == json!([]) { + vec![] + } else { + expected.clone() + }; + want.sort(); + assert_eq!(actual, want, "kinds={kinds}, h={values}"); + } + } + pool.close().await; +} diff --git a/crates/buzz-relay/src/handlers/history/tests/paging.rs b/crates/buzz-relay/src/handlers/history/tests/paging.rs new file mode 100644 index 00000000000..aa46e0df87d --- /dev/null +++ b/crates/buzz-relay/src/handlers/history/tests/paging.rs @@ -0,0 +1,236 @@ +//! Actual HTTP handler + signed NIP-98 + isolated Postgres/Redis, no fake pages. +use super::*; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + Json, +}; +use base64::Engine; +use nostr::{Event, EventBuilder, Kind, Tag}; +use sha2::{Digest, Sha256}; + +async fn query_http( + state: Arc, + host: &str, + signer: &Keys, + filter: Value, +) -> Result, (StatusCode, Json)> { + let body = serde_json::to_vec(&json!([filter])).unwrap(); + let url = format!( + "{}://{host}/query", + if state.config.relay_url.starts_with("wss:") { + "https" + } else { + "http" + } + ); + let auth = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", &url]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(Sha256::digest(&body))]).unwrap(), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(signer) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("host", host.parse().unwrap()); + headers.insert( + "authorization", + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&auth).unwrap()) + ) + .parse() + .unwrap(), + ); + crate::api::bridge::query_events(State(state), headers, body.into()).await +} + +#[tokio::test] +#[ignore = "requires isolated Postgres MULTIVERSE_TEST_DATABASE_URL and Redis REDIS_URL"] +async fn host_http_pages_exhaust_timestamp_ties_and_never_leak_other_owners() { + let url = std::env::var("MULTIVERSE_TEST_DATABASE_URL").expect("isolated DB required"); + let pool = sqlx::PgPool::connect(&url).await.unwrap(); + let db = Db::from_pool(pool.clone()); + let hostname = format!("host-pages-{}.test", Uuid::new_v4()); + let community = db.ensure_configured_community(&hostname).await.unwrap(); + let owner = Keys::generate(); + let host = Keys::generate(); + let other = Keys::generate(); + let timestamp = nostr::Timestamp::now().as_secs(); + let mut registrations = vec![]; + let mut profiles = vec![]; + let report = buzz_core::host::Report { + v: 2, + name: "Private test machine".into(), + os: "test".into(), + arch: "test".into(), + launcher_version: "test".into(), + runtimes: vec![], + accepts_start: false, + provisioned: vec![], + }; + // All records tie on timestamp. No event_mentions are populated. The + // profile query must not lose its rows to a registration candidate page. + for _ in 0..1001 { + let reg = buzz_core::host::registration(&owner, host.public_key(), timestamp).unwrap(); + let profile = buzz_core::host::profile(&host, ®, &report, timestamp).unwrap(); + for e in [®, &profile] { + buzz_db::event::insert_event(&pool, community.id, e, None) + .await + .unwrap(); + } + registrations.push(reg.id.to_hex()); + profiles.push(profile.id.to_hex()); + } + let foreign = buzz_core::host::registration(&other, host.public_key(), timestamp + 1).unwrap(); + buzz_db::event::insert_event(&pool, community.id, &foreign, None) + .await + .unwrap(); + let state = state_with_redis( + db, + pool.clone(), + std::env::var("REDIS_URL").expect("isolated Redis required"), + ) + .await; + let tenant = TenantContext::resolved(community.id, &hostname); + let (_conn, _rx) = connection(&state, tenant, &owner); + for (label, author, mut expected) in [ + ("registration", owner.public_key(), registrations), + ("profile", host.public_key(), profiles), + ] { + let mut filter = json!({"kinds":[50000], "authors":[author.to_hex()], "#p":[owner.public_key().to_hex()], "#L":["buzz.host.v1"], "#l":[label], "#x":[host.public_key().to_hex()], "limit":1000}); + let mut ids = vec![]; + let mut sizes = vec![]; + loop { + let Json(value) = query_http(state.clone(), &hostname, &owner, filter.clone()) + .await + .unwrap(); + let mut events: Vec = serde_json::from_value(value).unwrap(); + events.sort_by_key(|e| e.id.to_hex()); + sizes.push(events.len()); + ids.extend(events.iter().map(|e| e.id.to_hex())); + if events.len() < 1000 { + break; + } + let last = events.last().unwrap(); + filter["until"] = json!(last.created_at.as_secs()); + filter["before_id"] = json!(last.id.to_hex()); + } + expected.sort(); + assert_eq!(ids, expected); + assert_eq!(sizes, [1000, 1]); + // Reusing an owner's filters from a different signed identity is denied. + let denied = query_http(state.clone(), &hostname, &other, filter) + .await + .unwrap_err(); + assert_eq!(denied.0, StatusCode::FORBIDDEN); + } + let Json(rows) = query_http( + state.clone(), + &hostname, + &owner, + json!({"ids":[foreign.id.to_hex()], "limit":1}), + ) + .await + .unwrap(); + assert_eq!(rows, json!([])); + // Malformed composite cursor is a failure, never an empty successful page. + let bad = query_http(state, &hostname, &owner, json!({"kinds":[50000], "#p":[owner.public_key().to_hex()], "before_id":"bad", "until":timestamp})).await.unwrap_err(); + assert_eq!(bad.0, StatusCode::BAD_REQUEST); + pool.close().await; +} + +#[tokio::test] +#[ignore = "requires isolated Postgres MULTIVERSE_TEST_DATABASE_URL and Redis REDIS_URL"] +async fn start_http_signed_queries_are_owner_only_without_mentions_or_search_leaks() { + use buzz_core::{ + host, + host_execution::{self, Action, Command, Outcome, Receipt}, + }; + let url = std::env::var("MULTIVERSE_TEST_DATABASE_URL").expect("isolated DB required"); + let pool = sqlx::PgPool::connect(&url).await.unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("start-query-{}.test", Uuid::new_v4())) + .await + .unwrap(); + let owner = Keys::generate(); + let host = Keys::generate(); + let stranger = Keys::generate(); + let now = nostr::Timestamp::now().as_secs(); + let reg = host::registration(&owner, host.public_key(), now).unwrap(); + let req = Command { + v: 1, + operation: "ab".repeat(16), + agent: Keys::generate().public_key().to_hex(), + relay: "wss://fixture.test".into(), + expires_at: now + 300, + action: Action::Start { + runtime: "goose".into(), + revision: "cd".repeat(32), + }, + }; + let cmd = host_execution::command(&owner, ®, &req, now).unwrap(); + let receipt = host_execution::receipt( + &host, + ®, + &Receipt { + v: 1, + command: cmd.id.to_hex(), + run: req.run().into(), + request: req, + outcome: Outcome::Spawned, + observed_at: now, + }, + now, + ) + .unwrap(); + for e in [®, &cmd, &receipt] { + buzz_db::event::insert_event(&pool, community.id, e, None) + .await + .unwrap(); + } + let state = state_with_redis(db, pool.clone(), std::env::var("REDIS_URL").unwrap()).await; + for event in [&cmd, &receipt] { + let filter = json!({"kinds":[event.kind.as_u16()], "#p":[owner.public_key().to_hex()], "#e":[reg.id.to_hex()], "limit":1000}); + let Json(value) = query_http(state.clone(), &community.host, &owner, filter.clone()) + .await + .unwrap(); + let rows: Vec = serde_json::from_value(value).unwrap(); + assert_eq!( + rows, + vec![event.clone()], + "no event_mentions index required" + ); + for signer in [&stranger, &host] { + assert_eq!( + query_http(state.clone(), &community.host, signer, filter.clone()) + .await + .unwrap_err() + .0, + StatusCode::FORBIDDEN + ); + let Json(value) = query_http( + state.clone(), + &community.host, + signer, + json!({"ids":[event.id.to_hex()]}), + ) + .await + .unwrap(); + assert_eq!( + value, + json!([]), + "kindless IDs must not bypass result gates" + ); + } + let error = query_http(state.clone(), &community.host, &owner, json!({"kinds":[event.kind.as_u16()], "#p":[owner.public_key().to_hex()], "search":"fixture"})).await.unwrap_err(); + assert_eq!( + error.0, + StatusCode::BAD_REQUEST, + "private host history rejects search rather than invoking FTS" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/hosts.rs b/crates/buzz-relay/src/handlers/hosts.rs new file mode 100644 index 00000000000..a2501019470 --- /dev/null +++ b/crates/buzz-relay/src/handlers/hosts.rs @@ -0,0 +1,209 @@ +//! Host transport is not delegation: only the registered owner may submit it. +use buzz_core::{host, tenant::TenantContext}; +use nostr::Event; + +use super::ingest::{IngestAuth, IngestError}; +use crate::state::AppState; + +fn invalid(error: String) -> IngestError { + IngestError::Rejected(format!("invalid: {error}")) +} + +fn envelope(event: &Event, auth: &IngestAuth, now: u64) -> Result { + let env = host::validate(event).map_err(invalid)?; + if env.owner != *auth.pubkey() || auth.channel_ids().is_some() { + return Err(IngestError::AuthFailed( + "restricted: host events require their owner's global connection".into(), + )); + } + if event.created_at.as_secs() > now.saturating_add(30) { + return Err(invalid("host timestamp is in the future".into())); + } + if env.valid_until.is_some_and(|until| until <= now) { + return Err(invalid("expired host report".into())); + } + Ok(env) +} + +fn binding(env: &host::Envelope, registration: &Event) -> Result<(), IngestError> { + let reg = host::validate(registration).map_err(invalid)?; + if reg.label != "registration" + || reg.host != env.host + || reg.owner != env.owner + || env.registration.as_deref() != Some(registration.id.to_hex().as_str()) + { + return Err(invalid("host registration binding mismatch".into())); + } + Ok(()) +} + +pub(super) async fn authorize( + tenant: &TenantContext, + state: &AppState, + event: &Event, + auth: &IngestAuth, +) -> Result<(), IngestError> { + let env = envelope(event, auth, nostr::Timestamp::now().as_secs())?; + if let Some(id) = env.registration.as_deref() { + let bytes = hex::decode(id).map_err(|e| invalid(e.to_string()))?; + // A registration from another community or a deleted registration is + // not authority. The owner check above runs before this existence lookup. + let stored = state + .db + .get_event_by_id(tenant.community(), &bytes) + .await + .map_err(|e| IngestError::Internal(e.to_string()))? + .ok_or_else(|| invalid("host registration not found".into()))?; + binding(&env, &stored.event)?; + } + Ok(()) +} + +/// Owner-transported, host-signed pulse. Registration is NOT a host login grant. +pub(super) async fn authorize_presence( + tenant: &TenantContext, + state: &AppState, + event: &Event, + owner: nostr::PublicKey, + channel_scoped: bool, +) -> Result<(), String> { + let pulse = buzz_core::run_presence::parse_run(event, nostr::Timestamp::now().as_secs())? + .ok_or("host presence requires a run")?; + let id = pulse + .registration + .as_deref() + .ok_or("host presence requires registration")?; + if channel_scoped || pulse.location.is_some() { + return Err("invalid host presence transport".into()); + } + let stored = state + .db + .get_event_by_id( + tenant.community(), + &hex::decode(id).map_err(|_| "invalid registration")?, + ) + .await + .map_err(|_| "host registration lookup failed")? + .ok_or("host registration not found")?; + let binding = host::validate(&stored.event)?; + if binding.label != "registration" || binding.owner != owner || binding.host != event.pubkey { + return Err("host presence registration mismatch".into()); + } + // The owner remains the authenticated principal. No host key admission, + // general EVENT, REQ or HTTP privileges are created by this exception. + Ok(()) +} + +pub(super) async fn authorize_execution( + tenant: &TenantContext, + state: &AppState, + event: &Event, + auth: &IngestAuth, +) -> Result<(), IngestError> { + // Reject foreign/scoped callers before looking up private registration IDs. + let owner = auth.pubkey().to_hex(); + if auth.channel_ids().is_some() + || event + .tags + .iter() + .filter(|t| t.as_slice() == ["p", owner.as_str()]) + .count() + != 1 + { + return Err(IngestError::AuthFailed( + "restricted: execution requires owner transport".into(), + )); + } + let ids: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().is_some_and(|s| s == "e")) + .collect(); + let [id] = ids.as_slice() else { + return Err(invalid("invalid execution registration".into())); + }; + let tag = id.as_slice(); + if tag.len() != 2 || !buzz_core::host_execution::hex_id(&tag[1], 64) { + return Err(invalid("invalid execution registration".into())); + } + let stored = state + .db + .get_event_by_id( + tenant.community(), + &hex::decode(&tag[1]).map_err(|_| invalid("invalid registration".into()))?, + ) + .await + .map_err(|_| IngestError::Internal("registration lookup failed".into()))? + .ok_or_else(|| invalid("execution registration absent or revoked".into()))?; + buzz_core::host_execution::validate_transport(event, &stored.event, *auth.pubkey()) + .map_err(invalid) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_auth::Scope; + use nostr::Keys; + use uuid::Uuid; + + fn auth(keys: &Keys) -> IngestAuth { + IngestAuth::Nip42 { + pubkey: keys.public_key(), + scopes: vec![Scope::UsersWrite], + channel_ids: None, + conn_id: Uuid::new_v4(), + } + } + + #[test] + fn transport_and_registration_binding_fail_closed() { + let owner = Keys::generate(); + let host = Keys::generate(); + let other = Keys::generate(); + let reg = host::registration(&owner, host.public_key(), 100).unwrap(); + let rep = host::report( + &host, + ®, + &host::Report { + v: 1, + name: "test".into(), + os: "macos".into(), + arch: "aarch64".into(), + launcher_version: "test".into(), + runtimes: vec![], + accepts_start: false, + provisioned: vec![], + }, + 101, + ) + .unwrap(); + let owner_auth = auth(&owner); + for event in [®, &rep] { + assert!(envelope(event, &owner_auth, 101).is_ok()); + assert!(envelope(event, &auth(&other), 101).is_err()); + // Independent host connections are deliberately not enabled yet. + assert!(envelope(event, &auth(&host), 101).is_err()); + let mut scoped = owner_auth.clone(); + if let IngestAuth::Nip42 { channel_ids, .. } = &mut scoped { + *channel_ids = Some(vec![]); + } + assert!(envelope(event, &scoped, 101).is_err()); + assert!(envelope(event, &owner_auth, 0).is_err()); + } + assert!(envelope(&rep, &owner_auth, 280).is_ok()); + assert!(envelope(&rep, &owner_auth, 281).is_err()); + let env = envelope(&rep, &owner_auth, 101).unwrap(); + assert!(binding(&env, ®).is_ok()); + for wrong in [ + host::registration(&other, host.public_key(), 100).unwrap(), + host::registration(&owner, other.public_key(), 100).unwrap(), + host::registration(&owner, host.public_key(), 99).unwrap(), + rep.clone(), + ] { + assert!(binding(&env, &wrong).is_err()); + } + let mut tampered = rep; + tampered.content.push('A'); + assert!(envelope(&tampered, &owner_auth, 101).is_err()); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index dd2fa6e93e0..e0ed011ad35 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -436,7 +436,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { /// Returns `Err` for unknown kinds — the relay rejects them. fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { - KIND_PROFILE => Ok(Scope::UsersWrite), + KIND_PROFILE | buzz_core::kind::KIND_HOST | buzz_core::kind::KIND_HOST_COMMAND | buzz_core::kind::KIND_HOST_RECEIPT => Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT @@ -695,6 +695,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // NIP-AM: agent turn metrics are owner-scoped global events. // Channel identity is encrypted inside the payload — no `h` tag. | KIND_AGENT_TURN_METRIC + | buzz_core::kind::KIND_HOST + | buzz_core::kind::KIND_HOST_COMMAND + | buzz_core::kind::KIND_HOST_RECEIPT // NIP-PL leases are author-owned, addressable global state. | super::push_lease::KIND_PUSH_LEASE ) @@ -2240,7 +2243,20 @@ async fn ingest_event_inner( } let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP; - if event.pubkey != *auth.pubkey() && !is_gift_wrap { + // Only validated host events have this narrow transport exception. + let host_owner_transport = if kind_u32 == buzz_core::kind::KIND_HOST { + super::hosts::authorize(tenant, state, &event, &auth).await?; + true + } else if matches!( + kind_u32, + buzz_core::kind::KIND_HOST_COMMAND | buzz_core::kind::KIND_HOST_RECEIPT + ) { + super::hosts::authorize_execution(tenant, state, &event, &auth).await?; + true + } else { + false + }; + if event.pubkey != *auth.pubkey() && !is_gift_wrap && !host_owner_transport { return Err(IngestError::AuthFailed( "invalid: event pubkey does not match authenticated identity".into(), )); diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index d1c56a2b48f..5ff67e32770 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -64,3 +64,8 @@ pub fn resolve_ttl(event: &nostr::Event, ephemeral_ttl_override: Option) -> (ttl, _) => ttl, } } + +/// Authoritative host history shared by WebSocket and HTTP reads. +pub(crate) mod history; +/// Private host registration authorization. +mod hosts; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index d299cc045fa..c291fde5f42 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,8 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, + is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, P_GATED_KINDS, + RESULT_GATED_KINDS, SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -246,6 +246,17 @@ pub async fn handle_req( // already ran, so an authed member cannot use search to bypass author/#p // rules for kind:30174 or other globally-stored gated kinds. let has_search = filters.iter().any(|f| f.search.is_some()); + if has_search + && filters + .iter() + .any(super::history::explicitly_requests_hosts) + { + conn.send(RelayMessage::closed( + &sub_id, + "error: host history search is unsupported", + )); + return; + } if has_search { if filters.iter().any(|f| f.search.is_none()) { conn.send(RelayMessage::closed( @@ -359,16 +370,19 @@ pub async fn handle_req( .collect(); // Phase 2 — DB reads, bounded-concurrent. `buffered` (not `buffer_unordered`) - // yields results in input order, so phase 3 observes filters in their - // original order and NIP-01 dedupe / conformance-trace / error semantics are - // byte-identical to the previous serial loop. + // yields results in input order, so phase 3 preserves NIP-01 dedupe and + // conformance-trace ordering. Host-capable query failures below explicitly + // close the entire history rather than reporting partial success. use futures_util::stream::{self, StreamExt}; let db = state.db.clone(); + let requires_primary = filters.iter().any(super::history::requires_primary); let mut results = stream::iter(filter_queries.into_iter().map( |(idx, per_filter_channel, params)| { let db = db.clone(); + let filter = &filters[idx]; async move { - let filter_events = db.query_events_routed("req_historical", ¶ms).await; + let filter_events = + super::history::query(&db, "req_historical", filter, params).await; (idx, per_filter_channel, filter_events) } }, @@ -382,7 +396,20 @@ pub async fn handle_req( Ok(evs) => evs, Err(e) => { warn!(conn_id = %conn_id, sub_id = %sub_id, "Historical query failed: {e}"); - conn.send(RelayMessage::eose(&sub_id)); + if requires_primary { + // CLOSED is failure, EOSE is successful completion. Remove + // the live subscription as well: no continuing fan-out on a + // read whose partial history the client must discard. + conn.subscriptions.lock().await.remove(&sub_id); + if let Some(removed) = state.sub_registry.remove_subscription(conn_id, &sub_id) + { + release_subscription_topics(&state, &conn.tenant, &removed.scope).await; + } + conn.send(RelayMessage::closed(&sub_id, e.wire_message())); + } else { + // Preserve legacy behavior for unrelated history reads. + conn.send(RelayMessage::eose(&sub_id)); + } return; } }; @@ -1202,7 +1229,7 @@ pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: let explicitly_no_ids_exemption = filter.kinds.as_ref().is_some_and(|ks| { ks.iter().any(|kind| { let k = kind.as_u16() as u32; - k == KIND_DM_VISIBILITY || k == KIND_AGENT_TURN_METRIC + RESULT_GATED_KINDS.contains(&k) }) }); if !explicitly_no_ids_exemption && filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { @@ -1877,6 +1904,55 @@ mod tests { assert!(filters.iter().any(|f| f.search.is_some())); } + #[test] + fn private_host_filters_and_known_id_counts_require_owner() { + let owner = nostr::Keys::generate(); + let other = nostr::Keys::generate(); + let host = nostr::Keys::generate(); + let event = buzz_core::host::registration(&owner, host.public_key(), 100).unwrap(); + let kind = nostr::Kind::Custom(buzz_core::kind::KIND_HOST as u16); + let p = SingleLetterTag::lowercase(Alphabet::P); + let owner_hex = owner.public_key().to_hex(); + let other_hex = other.public_key().to_hex(); + for filter in [ + Filter::new().kind(kind), + Filter::new().kind(kind).id(event.id), + Filter::new().kind(kind).custom_tags(p, [&owner_hex]), + Filter::new() + .kind(kind) + .custom_tags(p, [&owner_hex, &other_hex]), + Filter::new() + .kinds([kind, nostr::Kind::TextNote]) + .custom_tags(p, [&owner_hex]), + ] { + assert!(!p_gated_filters_authorized( + std::slice::from_ref(&filter), + &other_hex + )); + assert!(filter_can_match_result_gated_kinds(&filter)); + } + let own = Filter::new() + .kind(kind) + .id(event.id) + .custom_tags(p, [&owner_hex]); + assert!(p_gated_filters_authorized(&[own], &owner_hex)); + // A kindless ID query passes the filter gate, but must count/return no + // private rows to a foreign reader. Pin both sides of that composition. + let ids = Filter::new().id(event.id); + assert!(p_gated_filters_authorized( + std::slice::from_ref(&ids), + &other_hex + )); + assert!(filter_can_match_result_gated_kinds(&ids)); + assert!(!result_gated_count_safe_for_pushdown(&ids, &other_hex)); + assert!(!buzz_core::filter::reader_authorized_for_event( + &event, &other_hex + )); + assert!(buzz_core::filter::reader_authorized_for_event( + &event, &owner_hex + )); + } + #[test] fn dm_visibility_requires_p_tag_even_with_ids() { let p_tag = SingleLetterTag::lowercase(Alphabet::P); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d81602e2019..f0b617da767 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::net::SocketAddr; use std::sync::atomic::Ordering; use std::sync::Arc; @@ -47,6 +48,13 @@ async fn connect_audit_pool(config: &DbConfig) -> anyhow::Result { .map_err(Into::into) } +// Health and metrics must honor the main listener's interface restriction. +// The default main address is 0.0.0.0, so default wildcard behavior is unchanged. +fn auxiliary_listener_addr(mut bind_addr: SocketAddr, port: u16) -> SocketAddr { + bind_addr.set_port(port); + bind_addr +} + fn relay_keypair_from_config(relay_private_key: Option<&str>) -> anyhow::Result { let hex = relay_private_key.ok_or_else(|| { anyhow::anyhow!( @@ -179,7 +187,10 @@ async fn main() -> anyhow::Result<()> { let usage_interval_secs = usage_metrics_interval_secs(); let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); - relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); + relay_metrics::install( + auxiliary_listener_addr(config.bind_addr, config.metrics_port), + usage_idle_timeout_secs, + ); metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 }); metrics::gauge!("buzz_push_enabled").set(if config.push_enabled { 1.0 } else { 0.0 }); info!( @@ -1303,9 +1314,12 @@ async fn serve( ) -> anyhow::Result<()> { let config = &state.config; - let health_listener = tokio::net::TcpListener::bind(("0.0.0.0", config.health_port)) - .await - .map_err(|e| anyhow::anyhow!("Failed to bind health port {}: {e}", config.health_port))?; + let health_listener = tokio::net::TcpListener::bind(auxiliary_listener_addr( + config.bind_addr, + config.health_port, + )) + .await + .map_err(|e| anyhow::anyhow!("Failed to bind health port {}: {e}", config.health_port))?; info!(port = config.health_port, "Health probe listener started"); tokio::spawn(async move { axum::serve(health_listener, health_router).await.ok(); @@ -2159,6 +2173,63 @@ mod tests { .expect("release audit advisory lock"); } + #[test] + fn auxiliary_listeners_preserve_main_interface_and_override_only_port() { + for main in [ + "127.0.0.1:53001", + "[::1]:53001", + "0.0.0.0:3000", + "[::]:3000", + "192.0.2.1:3000", + "[fe80::1%3]:3000", + ] { + let main: std::net::SocketAddr = main.parse().unwrap(); + for port in [58081, 59101] { + let actual = super::auxiliary_listener_addr(main, port); + assert_eq!(actual.ip(), main.ip()); + assert_eq!(actual.port(), port); + let mut restored = actual; + restored.set_port(main.port()); + assert_eq!(restored, main, "preserve IPv6 scope and flow information"); + } + } + } + + #[tokio::test] + async fn auxiliary_listener_binds_only_configured_loopback() { + for main in ["127.0.0.1:53001", "[::1]:53001"] { + let main: std::net::SocketAddr = main.parse().unwrap(); + let listener = tokio::net::TcpListener::bind(super::auxiliary_listener_addr(main, 0)) + .await + .unwrap(); + assert_eq!(listener.local_addr().unwrap().ip(), main.ip()); + assert_ne!(listener.local_addr().unwrap().port(), 0); + } + } + + // Keep the global recorder install in the binary test process, separate + // from library tests. This exercises the real exporter, not just addresses. + #[tokio::test] + async fn metrics_exporter_uses_configured_loopback_interface() { + let reservation = std::net::TcpListener::bind("[::1]:0").unwrap(); + let addr = reservation.local_addr().unwrap(); + // A hard-coded IPv4 wildcard exporter would collide with this socket. + let _ipv4_guard = std::net::TcpListener::bind(("127.0.0.1", addr.port())).unwrap(); + drop(reservation); + super::relay_metrics::install(addr, 600); + + let response = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap() + .get(format!("http://{addr}/metrics")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + } + #[test] fn buzz_auto_migrate_is_opt_in() { assert!(!buzz_auto_migrate_enabled(None)); diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index cd7c427c72e..7fe29136daa 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -315,24 +315,40 @@ pub(crate) async fn run_demo_echo( tracing::info!(%session_id, %peer, "mesh demo echo: session open"); let mut drain_tick = tokio::time::interval(std::time::Duration::from_millis(100)); loop { - let frame = tokio::select! { - _ = drain_tick.tick() => { - if shutting_down.load(Ordering::Relaxed) { - if let Some(community_id) = stream.community_id() { - if let Err(e) = stream.send_goodbye(community_id, GoodbyeReason::Draining).await { - tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); - } else { - tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + let frame = { + // Receiving is not cancellation-safe: it may have consumed bytes or + // a whole frame before awaiting Redis validation. Retain this future + // across housekeeping ticks; only terminal drain may discard it. + let recv = stream.recv_validated(&directory); + tokio::pin!(recv); + loop { + tokio::select! { + _ = drain_tick.tick() => { + if shutting_down.load(Ordering::Relaxed) { + break None; } - } else { - let _ = stream.finish(); - tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); } - return; + frame = &mut recv => break Some(frame), } - continue; } - frame = stream.recv_validated(&directory) => frame, + }; + // The receive future (and its mutable stream borrow) is gone before + // either the drain response or the next echo uses the stream. + let Some(frame) = frame else { + if let Some(community_id) = stream.community_id() { + if let Err(e) = stream + .send_goodbye(community_id, GoodbyeReason::Draining) + .await + { + tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); + } else { + tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + } + } else { + let _ = stream.finish(); + tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); + } + return; }; match frame { Ok(Some(ReliableFrame::Data(payload))) => { @@ -520,6 +536,9 @@ pub async fn boot_mesh( })) } +#[cfg(test)] +mod echo_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay/src/mesh_boot/echo_tests.rs b/crates/buzz-relay/src/mesh_boot/echo_tests.rs new file mode 100644 index 00000000000..96dd6783bdc --- /dev/null +++ b/crates/buzz-relay/src/mesh_boot/echo_tests.rs @@ -0,0 +1,310 @@ +//! Consumer-boundary tests: real reliable framing/Redis validation, controlled +//! transport halves. Redis tests are explicit (never silently skipped): +//! REDIS_URL=... cargo test -p buzz-relay mesh_boot::echo_tests -- --include-ignored + +use super::*; +use crate::tunnel::{ + directory::{AcquireResult, SessionLease}, + reliable::ReliableMeshStream, +}; +use buzz_relay_mesh::{ + BoxFuture, FencedHeader, MeshError, MeshStreamFrame, StreamRecvHalf, StreamSendHalf, +}; +use futures_util::poll; +use std::{future::Future, pin::Pin, sync::atomic::AtomicUsize, time::Duration}; +use tokio::sync::mpsc; + +#[derive(Default)] +struct Observed { + receives: AtomicUsize, + consumed: AtomicUsize, + finishes: AtomicUsize, +} + +struct SendHalf(mpsc::UnboundedSender, Arc); +impl StreamSendHalf for SendHalf { + fn send_frame(&mut self, frame: MeshStreamFrame) -> BoxFuture<'_, Result<(), MeshError>> { + Box::pin(async move { + self.0.send(frame).unwrap(); + Ok(()) + }) + } + fn finish(&mut self) -> Result<(), MeshError> { + self.1.finishes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +struct RecvHalf(mpsc::UnboundedReceiver, Arc); +impl StreamRecvHalf for RecvHalf { + fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>> { + self.1.receives.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + let frame = self.0.recv().await; + if frame.is_some() { + self.1.consumed.fetch_add(1, Ordering::SeqCst); + } + Ok(frame) + }) + } +} + +fn streams(fenced: FencedHeader) -> (ReliableInbound, ReliableMeshStream, Arc) { + let (input_tx, input_rx) = mpsc::unbounded_channel(); + let (output_tx, output_rx) = mpsc::unbounded_channel(); + let owner = Arc::new(Observed::default()); + let peer = Arc::new(Observed::default()); + let inbound = ReliableInbound { + fenced, + from: RuntimeId([18; 32]), + stream: ReliableMeshStream::new_inbound( + fenced, + MeshStream::new( + Box::new(SendHalf(output_tx, owner.clone())), + Box::new(RecvHalf(input_rx, owner.clone())), + ), + ), + }; + let peer_stream = ReliableMeshStream::new_inbound( + fenced, + MeshStream::new( + Box::new(SendHalf(input_tx, peer.clone())), + Box::new(RecvHalf(output_rx, peer)), + ), + ); + (inbound, peer_stream, owner) +} + +fn pool(url: String) -> deadpool_redis::Pool { + let mut config = deadpool_redis::Config::from_url(url); + config.pool = Some(deadpool_redis::PoolConfig::new(1)); + config + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap() +} + +struct Fixture { + pool: deadpool_redis::Pool, + directory: SessionDirectory, + lease: SessionLease, +} +impl Fixture { + async fn new() -> Self { + let pool = pool(std::env::var("REDIS_URL").expect("explicit test Redis required")); + let directory = SessionDirectory::with_lease_ttl(pool.clone(), Duration::from_secs(5)); + let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let lease = match directory + .acquire( + community, + uuid::Uuid::new_v4(), + RuntimeId([17; 32]), + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + _ => panic!("UUID collision"), + }; + Self { + pool, + directory, + lease, + } + } + + async fn cleanup(self) { + self.directory.release(&self.lease).await.unwrap(); + let mut conn = self.pool.get().await.unwrap(); + let community = self.lease.community_id; + let session = self.lease.session_id; + let _: () = redis::cmd("DEL") + .arg(format!("buzz:{community}:tunnel:{session}:generation")) + .query_async(&mut *conn) + .await + .unwrap(); + } +} + +// Drive the actual consumer ourselves: a held private pool slot guarantees +// validation is pending, and each poll after 110ms must service a 100ms tick. +// No spawned-task scheduling assumption decides whether the frame was consumed +// or the consumer crossed a tick. The Redis server/other pools are never blocked. +async fn housekeeping(consumer: &mut Pin<&mut impl Future>) { + tokio::time::sleep(Duration::from_millis(110)).await; + assert!(poll!(consumer.as_mut()).is_pending()); +} + +#[tokio::test] +#[ignore = "requires explicit REDIS_URL"] +async fn consumed_frame_survives_housekeeping_during_validation() { + let f = Fixture::new().await; + let (inbound, mut peer, seen) = streams(f.lease.fenced_header()); + let held = f.pool.get().await.unwrap(); + let shutdown = Arc::new(AtomicBool::new(false)); + let consumer = run_demo_echo(f.directory.clone(), inbound, shutdown); + tokio::pin!(consumer); + assert!(poll!(&mut consumer).is_pending()); + peer.send_bytes(f.lease.community_id, b"first") + .await + .unwrap(); + assert!(poll!(&mut consumer).is_pending()); + assert_eq!(seen.consumed.load(Ordering::SeqCst), 1); + for _ in 0..3 { + housekeeping(&mut consumer).await; + } + assert_eq!( + seen.receives.load(Ordering::SeqCst), + 1, + "pending receive was recreated" + ); + drop(held); + tokio::time::timeout(Duration::from_secs(2), async { + let receive = async { + assert!(matches!(peer.recv_validated(&f.directory).await.unwrap(), + Some(ReliableFrame::Data(bytes)) if bytes == b"first")); + peer.send_bytes(f.lease.community_id, b"second") + .await + .unwrap(); + assert!(matches!(peer.recv_validated(&f.directory).await.unwrap(), + Some(ReliableFrame::Data(bytes)) if bytes == b"second")); + peer.send_goodbye(f.lease.community_id, GoodbyeReason::Draining) + .await + .unwrap(); + }; + tokio::join!(&mut consumer, receive); + }) + .await + .expect("echoes and peer Goodbye must terminate"); + assert_eq!(seen.consumed.load(Ordering::SeqCst), 3); + assert!( + peer.recv_validated(&f.directory).await.unwrap().is_none(), + "no duplicate echo" + ); + f.cleanup().await; +} + +async fn drain_during_validation(latched: bool) { + let f = Fixture::new().await; + let (inbound, mut peer, seen) = streams(f.lease.fenced_header()); + let shutdown = Arc::new(AtomicBool::new(false)); + let consumer = run_demo_echo(f.directory.clone(), inbound, shutdown.clone()); + tokio::pin!(consumer); + if latched { + peer.send_bytes(f.lease.community_id, b"latch") + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + tokio::select! { + _ = &mut consumer => panic!("premature termination"), + frame = peer.recv_validated(&f.directory) => assert!(matches!(frame.unwrap(), + Some(ReliableFrame::Data(bytes)) if bytes == b"latch")), + } + }) + .await + .unwrap(); + } + let held = f.pool.get().await.unwrap(); + peer.send_bytes(f.lease.community_id, b"pending") + .await + .unwrap(); + assert!(poll!(&mut consumer).is_pending()); + let count = if latched { 2 } else { 1 }; + assert_eq!(seen.consumed.load(Ordering::SeqCst), count); + housekeeping(&mut consumer).await; + shutdown.store(true, Ordering::Relaxed); + tokio::time::timeout(Duration::from_secs(1), &mut consumer) + .await + .unwrap(); + // Shutdown cannot wait for validation, and must not start another receive. + assert_eq!(seen.receives.load(Ordering::SeqCst), count); + assert_eq!(seen.finishes.load(Ordering::SeqCst), 1); + drop(held); + if latched { + assert!(matches!( + peer.recv_validated(&f.directory).await.unwrap(), + Some(ReliableFrame::Goodbye(GoodbyeReason::Draining)) + )); + } + assert!( + peer.recv_validated(&f.directory).await.unwrap().is_none(), + "unvalidated Data must not echo" + ); + f.cleanup().await; +} + +#[tokio::test] +#[ignore = "requires explicit REDIS_URL"] +async fn drain_before_latch_cancels_validation_and_finishes() { + drain_during_validation(false).await; +} + +#[tokio::test] +#[ignore = "requires explicit REDIS_URL"] +async fn drain_after_latch_cancels_validation_and_sends_goodbye() { + drain_during_validation(true).await; +} + +#[tokio::test] +#[ignore = "requires explicit REDIS_URL"] +async fn retained_receive_still_rejects_released_fence() { + let f = Fixture::new().await; + let (inbound, mut peer, seen) = streams(f.lease.fenced_header()); + let mut held = f.pool.get().await.unwrap(); + let consumer = run_demo_echo( + f.directory.clone(), + inbound, + Arc::new(AtomicBool::new(false)), + ); + tokio::pin!(consumer); + peer.send_bytes(f.lease.community_id, b"stale") + .await + .unwrap(); + assert!(poll!(&mut consumer).is_pending()); + housekeeping(&mut consumer).await; + // Remove only this test's lease while validation is still waiting for the + // pool slot. Keep its generation floor, just like a normal release. + let _: () = redis::cmd("DEL") + .arg(format!( + "buzz:{}:tunnel:{}:lease", + f.lease.community_id, f.lease.session_id + )) + .query_async(&mut *held) + .await + .unwrap(); + drop(held); + tokio::time::timeout(Duration::from_secs(2), &mut consumer) + .await + .unwrap(); + assert_eq!(seen.consumed.load(Ordering::SeqCst), 1); + assert!(peer.recv_validated(&f.directory).await.unwrap().is_none()); + f.cleanup().await; +} + +#[tokio::test] +async fn drain_idle_before_latch_and_eof_terminate_without_redis() { + for draining in [true, false] { + let directory = SessionDirectory::new(pool("redis://127.0.0.1:1".into())); + let fenced = FencedHeader { + session_id: uuid::Uuid::new_v4(), + generation: 1, + owner_runtime_id: RuntimeId([17; 32]), + }; + let (inbound, peer, seen) = streams(fenced); + let peer = if draining { + Some(peer) + } else { + drop(peer); + None + }; + tokio::time::timeout( + Duration::from_secs(1), + run_demo_echo(directory, inbound, Arc::new(AtomicBool::new(draining))), + ) + .await + .unwrap(); + assert_eq!(seen.finishes.load(Ordering::SeqCst), usize::from(draining)); + assert!(seen.receives.load(Ordering::SeqCst) <= 1); + drop(peer); + } +} diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 16e521a44ee..2eb14076751 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -14,6 +14,7 @@ //! recorded by [`track_metrics`] middleware on the app router. Buzz-specific //! metrics are recorded inline at their call sites. +use std::net::SocketAddr; use std::time::{Duration, Instant}; use axum::{ @@ -61,11 +62,12 @@ const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, /// `build()` returns the recorder + exporter future and internally spawns /// the upkeep task, so no separate upkeep call is needed. /// +/// Binds exactly `addr`, including its IP/interface restriction. /// Must be called from within a Tokio runtime. -/// Panics if a recorder is already installed or the port is in use. -pub fn install(port: u16, gauge_idle_timeout_secs: u64) { +/// Panics if a recorder is already installed or the address is unavailable. +pub fn install(addr: SocketAddr, gauge_idle_timeout_secs: u64) { let (recorder, exporter) = PrometheusBuilder::new() - .with_http_listener(([0, 0, 0, 0], port)) + .with_http_listener(addr) // Remove gauge series that the relay intentionally stops emitting. .idle_timeout( MetricKindMask::GAUGE, diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 68b702431af..39531d9ad31 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1107,6 +1107,7 @@ dependencies = [ "ed25519-dalek", "flate2", "futures-util", + "gethostname", "getrandom 0.2.17", "hex", "image", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f41fa2d6e39..d1e120e0eba 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -22,6 +22,10 @@ name = "buzz_lib" crate-type = ["staticlib", "cdylib", "rlib"] [features] +# Explicitly non-default: native Start tracer is not yet release-certified. +remote-start-preview = [] +# Isolated native executable harness; never enabled in release packaging. +remote-start-tracer = ["remote-start-preview"] default = ["system-keyring"] mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:mesh-llm-client", "dep:mesh-llm-node", "dep:mesh-llm-system", "dep:mesh-llm-events"] # OS keyring backing for desktop secret storage (nsec private keys). When @@ -68,6 +72,7 @@ keyring = { version = "3.6.3", default-features = false, features = ["windows-na user-idle = { version = "0.6", default-features = false } [dependencies] +gethostname = "1.1.0" atomic-write-file = "0.3" anyhow = "1" dirs = "6" @@ -157,3 +162,8 @@ tokio = { version = "1", features = ["test-util"] } # The relay's media validation, so the snapshot-sharing tests can prove the # full export → sanitize → relay-accept → import contract end to end. buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } + +[[bin]] +name = "host-start-tracer" +path = "src/bin/host_start_tracer.rs" +required-features = ["remote-start-tracer"] diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 9cbb4444ab3..375365a1430 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -155,12 +155,12 @@ fn identity_from_env() -> Option { } /// Build the no-redirect HTTP client used for authenticated relay media -/// fetches (download / copy). +/// fetches (download / copy) and owner-private host transport. /// /// This client is a security boundary, not a convenience: it carries a minted /// media `Authorization` header, so it MUST NOT follow redirects. A relay 3xx -/// to an off-origin or private host would otherwise forward that header across -/// origins (a redirect-hop SSRF). `redirect::Policy::none()` returns the 3xx +/// must not forward bodies or make redirect-hop requests, regardless of header +/// stripping. `redirect::Policy::none()` returns the 3xx /// verbatim so the caller can reject it. /// /// Returned as a `Result` so the fail-closed invariant is testable — callers diff --git a/desktop/src-tauri/src/bin/host_start_tracer.rs b/desktop/src-tauri/src/bin/host_start_tracer.rs new file mode 100644 index 00000000000..079eff6cbaa --- /dev/null +++ b/desktop/src-tauri/src/bin/host_start_tracer.rs @@ -0,0 +1,7 @@ +//! Separate native tracer entry point; never starts the normal Desktop services. +fn main() { + if let Err(error) = buzz_lib::run_host_start_tracer() { + eprintln!("Start tracer failed: {error}"); + std::process::exit(1); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index acee23f2f39..ee848878045 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -10,10 +10,10 @@ use crate::{ find_managed_agent_mut, load_managed_agents, load_personas, load_teams, managed_agent_avatar_url, normalize_agent_args, resolve_provider_binary, save_managed_agents, start_managed_agent_process, stop_managed_agent_process, - stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, - validate_provider_config, BackendKind, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, + ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -99,225 +99,11 @@ fn resolve_created_avatar_url( .or_else(|| managed_agent_avatar_url(agent_command)) } -#[cfg(feature = "mesh-llm")] -async fn ensure_relay_mesh_for_record( - app: &AppHandle, - model_id: Option<&str>, - allow_fresh_create_start: bool, -) -> Result<(), String> { - crate::commands::ensure_relay_mesh_for_record(app, model_id, allow_fresh_create_start).await -} - -#[cfg(not(feature = "mesh-llm"))] -async fn ensure_relay_mesh_for_record( - _app: &AppHandle, - _model_id: Option<&str>, - _allow_fresh_create_start: bool, -) -> Result<(), String> { - Ok(()) -} - -pub(super) async fn start_local_agent_pairs_with_preflight( - app: &AppHandle, - state: &AppState, - pubkey: &str, - relay_urls: &[String], -) -> Result { - let record_snapshot = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - load_managed_agents(app)? - .into_iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))? - }; - if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); - } - let personas_for_preflight = load_personas(app).unwrap_or_default(); - let global_for_preflight = - crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let mesh_model_id = - crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( - &record_snapshot, - &personas_for_preflight, - &global_for_preflight, - ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?; - - { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - let personas = load_personas(app).unwrap_or_default(); - if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); - } - } - save_managed_agents(app, &records)?; - if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); - } - } - - let mut errors = Vec::new(); - for relay_url in relay_urls { - if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy( - pubkey.to_string(), - relay_url.clone(), - app.clone(), - ) { - errors.push(format!("{relay_url}: {error}")); - } - } - if !errors.is_empty() { - return Err(format!( - "failed to restart one or more managed-agent runtime pairs: {}", - errors.join("; ") - )); - } - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let records = load_managed_agents(app)?; - let runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - summarize_from_disk(app, record, &runtimes) -} - -pub(super) async fn start_local_agent_with_preflight( - app: &AppHandle, - state: &AppState, - pubkey: &str, - allow_fresh_create_start: bool, - expected_relay_url: Option<&str>, - expected_signer_pubkey: Option<&str>, -) -> Result { - let record_snapshot = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let records = load_managed_agents(app)?; - records - .iter() - .find(|record| record.pubkey == pubkey) - .cloned() - .ok_or_else(|| format!("agent {pubkey} not found"))? - }; - - if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); - } - - // Preflight against the same resolution spawn uses — `resolve_effective_config` - // (definition → global fallback). A linked instance's own `provider`/`model`/ - // `relay_mesh` bytes never contribute: this reads the CURRENT definition - // directly, so a definition edit that flips `provider` to/from relay-mesh - // between saves is reflected here without needing a prospective re-snapshot; - // for a global-inherited blank definition, it also folds in the global - // default, which record-byte sniffing could never see. - let personas = load_personas(app).unwrap_or_default(); - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let mesh_model_id = - crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( - &record_snapshot, - &personas, - &global, - ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; - - // The mesh preflight above is the suspension window Projects callbacks - // capture their scope against: a community switch during that await - // would otherwise spawn this pair keyed to the *new* workspace relay. - // Read the workspace relay ONCE, assert the caller's captured scope - // against that exact read, and hand the same bound value to the spawn - // below — the check is tied to its use, so a switch landing after this - // point can no longer retarget the spawn (it only changes state this - // call no longer consults). - let workspace_relay_url = crate::relay::bind_expected_relay_scope( - expected_relay_url, - crate::relay::relay_ws_url_with_override(state), - )?; - // Bind the active owner after the same final await as the relay. A - // same-relay identity replacement during mesh preflight must not release - // the stale preflight owner to spawn. - let workspace_owner = - crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?; - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is no longer a local agent")); - } - // Re-snapshot the persona onto the record at every spawn so the agent always - // starts with the current persona config (system_prompt, model, provider, - // runtime). This clears the "out of date" drift badge without requiring a - // delete+recreate. See `apply_persona_snapshot` for the precedence and - // env-override self-heal rules. - // Load personas once: used for snapshot application below and summary build - // at the end — avoids a second disk read for the same file in the same call. - let personas = load_personas(app).unwrap_or_default(); - if let Some(persona_id) = record.persona_id.clone() { - match personas.iter().find(|p| p.id == persona_id) { - Some(persona) => { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); - } - None => { - return Err( - crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), - ); - } - } - } - start_managed_agent_process( - app, - record, - &mut runtimes, - Some(workspace_owner.as_str()), - &workspace_relay_url, - )?; - save_managed_agents(app, &records)?; - if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); - } - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - build_managed_agent_summary( - app, - record, - &runtimes, - &personas, - &load_teams(app).unwrap_or_default(), - &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), - ) -} +#[path = "agents_local_start.rs"] +mod local_start; +pub(super) use local_start::{ + start_local_agent_pairs_with_preflight, start_local_agent_with_preflight, +}; pub(crate) use provider_deploy::deploy_to_provider; @@ -958,8 +744,8 @@ pub async fn start_managed_agent( &state, &pubkey, false, - expected_relay_url.as_deref(), - expected_signer_pubkey.as_deref(), + Some(reconcile_relay.as_str()), + Some(&owner_hex), ) .await } @@ -980,8 +766,8 @@ pub async fn start_managed_agent( &config, agent_json, cached_binary_path.as_deref(), - expected_relay_url.as_deref(), - expected_signer_pubkey.as_deref(), + Some(reconcile_relay.as_str()), + Some(&owner_hex), ) .await?; @@ -1035,56 +821,9 @@ pub async fn start_managed_agent( result } -#[tauri::command] -pub async fn stop_managed_agent( - pubkey: String, - app: AppHandle, -) -> Result { - use tauri::Manager; - tokio::task::spawn_blocking(move || { - let state = app.state::(); - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; - - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); - } - - { - let record = find_managed_agent_mut(&mut records, &pubkey)?; - // Remote agents are stopped via !shutdown @mention from the frontend, - // not via this backend command. Reject the call. - if record.backend != BackendKind::Local { - return Err( - "remote agents are stopped via !shutdown message, not this command".to_string(), - ); - } - // Pair-scoped: stops only the active workspace's pair; delete and - // the config-restart flows still drain every pair. - stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?; - } - save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - summarize_from_disk(&app, record, &runtimes) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} +#[path = "agents_stop.rs"] +mod selected_stop; +pub use selected_stop::stop_managed_agent; // Async so the blocking body (disk reads/writes, process termination, keyring // delete, nest regeneration) runs off the main UI thread via spawn_blocking. diff --git a/desktop/src-tauri/src/commands/agents_local_start.rs b/desktop/src-tauri/src/commands/agents_local_start.rs new file mode 100644 index 00000000000..05ef798b71e --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_local_start.rs @@ -0,0 +1,383 @@ +//! Local launch preflight shared by explicit controls and config-driven starts. +use super::*; + +#[cfg(feature = "mesh-llm")] +async fn ensure_relay_mesh_for_record( + app: &AppHandle, + model_id: Option<&str>, + allow_fresh_create_start: bool, +) -> Result<(), String> { + crate::commands::ensure_relay_mesh_for_record(app, model_id, allow_fresh_create_start).await +} + +#[cfg(not(feature = "mesh-llm"))] +async fn ensure_relay_mesh_for_record( + _app: &AppHandle, + _model_id: Option<&str>, + _allow_fresh_create_start: bool, +) -> Result<(), String> { + Ok(()) +} + +pub(in crate::commands) async fn start_local_agent_pairs_with_preflight( + app: &AppHandle, + state: &AppState, + pubkey: &str, + relay_urls: &[String], +) -> Result { + let record_snapshot = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(app)? + .into_iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))? + }; + if record_snapshot.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + let personas_for_preflight = load_personas(app).unwrap_or_default(); + let global_for_preflight = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas_for_preflight, + &global_for_preflight, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?; + + { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + let personas = load_personas(app).unwrap_or_default(); + if let Some(persona_id) = record.persona_id.clone() { + if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) { + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = crate::util::now_iso(); + } + } + save_managed_agents(app, &records)?; + if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) { + retain_managed_agent_pending(app, state, saved_record); + } + } + + let mut errors = Vec::new(); + for relay_url in relay_urls { + if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy( + pubkey.to_string(), + relay_url.clone(), + app.clone(), + ) { + errors.push(format!("{relay_url}: {error}")); + } + } + if !errors.is_empty() { + return Err(format!( + "failed to restart one or more managed-agent runtime pairs: {}", + errors.join("; ") + )); + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(app)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + summarize_from_disk(app, record, &runtimes) +} + +// Shared continuation for no-journal Start and fresh create-start. Validate +// current state without replacing the captured values consumed by spawn. +fn revalidate_first_start_scope( + state: &AppState, + captured_relay_url: &str, + captured_owner: &str, +) -> Result<(), String> { + crate::relay::bind_expected_relay_scope( + Some(captured_relay_url), + crate::relay::relay_ws_url_with_override(state), + )?; + crate::relay::assert_expected_signer(Some(captured_owner), &workspace_owner_hex(state)?) +} + +// All local first-start callers share this suspension boundary. Explicit +// Start supplies its clicked scope; fresh create-start binds the command's +// entry scope here, BEFORE mesh discovery can suspend. Never bind a new +// workspace/owner on continuation merely because the caller was unscoped. +async fn scoped_local_preflight( + state: &AppState, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, + preflight: impl std::future::Future>, +) -> Result< + ( + crate::relay::ScopedWorkspaceRelay, + crate::relay::ScopedWorkspaceSigner, + ), + String, +> { + let relay = crate::relay::bind_expected_relay_scope( + expected_relay_url, + crate::relay::relay_ws_url_with_override(state), + )?; + let owner = + crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?; + preflight.await?; + revalidate_first_start_scope(state, relay.as_str(), owner.as_str())?; + Ok((relay, owner)) +} + +pub(in crate::commands) async fn start_local_agent_with_preflight( + app: &AppHandle, + state: &AppState, + pubkey: &str, + allow_fresh_create_start: bool, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, +) -> Result { + let record_snapshot = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(app)?; + records + .iter() + .find(|record| record.pubkey == pubkey) + .cloned() + .ok_or_else(|| format!("agent {pubkey} not found"))? + }; + + if record_snapshot.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute: this reads the CURRENT definition + // directly, so a definition edit that flips `provider` to/from relay-mesh + // between saves is reflected here without needing a prospective re-snapshot; + // for a global-inherited blank definition, it also folds in the global + // default, which record-byte sniffing could never see. + let personas = load_personas(app).unwrap_or_default(); + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas, + &global, + ); + let (workspace_relay_url, workspace_owner) = scoped_local_preflight( + state, + expected_relay_url, + expected_signer_pubkey, + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start), + ) + .await?; + + if !allow_fresh_create_start { + let app_for_start = app.clone(); + let agent_for_start = pubkey.to_owned(); + let relay_for_start = workspace_relay_url.as_str().to_owned(); + let owner_for_start = workspace_owner.as_str().to_owned(); + let resumed = tokio::task::spawn_blocking(move || { + crate::managed_agents::start_after_exact_stop( + &app_for_start, + &agent_for_start, + &relay_for_start, + &owner_for_start, + ) + }) + .await + .map_err(|_| "explicit Start task failed")??; + if resumed { + let records = load_managed_agents(app)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("agent not found")?; + return summarize_from_disk(app, record, &runtimes); + } + } + + // Recovery adds another suspension even when there is no journal yet. + // Revalidate the captured pair/owner before the legacy first-start path; + // never let that await retarget or authorize a stale workspace action. + revalidate_first_start_scope( + state, + workspace_relay_url.as_str(), + workspace_owner.as_str(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is no longer a local agent")); + } + // Re-snapshot the persona onto the record at every spawn so the agent always + // starts with the current persona config (system_prompt, model, provider, + // runtime). This clears the "out of date" drift badge without requiring a + // delete+recreate. See `apply_persona_snapshot` for the precedence and + // env-override self-heal rules. + // Load personas once: used for snapshot application below and summary build + // at the end — avoids a second disk read for the same file in the same call. + let personas = load_personas(app).unwrap_or_default(); + if let Some(persona_id) = record.persona_id.clone() { + match personas.iter().find(|p| p.id == persona_id) { + Some(persona) => { + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = crate::util::now_iso(); + } + None => { + return Err( + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + ); + } + } + } + start_managed_agent_process( + app, + record, + &mut runtimes, + Some(workspace_owner.as_str()), + &workspace_relay_url, + )?; + save_managed_agents(app, &records)?; + if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { + retain_managed_agent_pending(app, state, saved_record); + } + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + build_managed_agent_summary( + app, + record, + &runtimes, + &personas, + &load_teams(app).unwrap_or_default(), + &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), + ) +} + +#[cfg(test)] +mod scope_tests { + use super::*; + + #[tokio::test] + async fn create_start_preflight_retains_entry_scope_without_a_caller_pin() { + let state = crate::app_state::build_app_state(); + let owner = workspace_owner_hex(&state).unwrap(); + for relay in ["ws://localhost:3000", "wss://community.example"] { + *state.relay_url_override.lock().unwrap() = Some(relay.to_owned()); + let (bound_relay, bound_owner) = scoped_local_preflight(&state, None, None, async { + tokio::task::yield_now().await; + Ok(()) + }) + .await + .unwrap(); + assert_eq!(bound_relay.as_str(), relay); + assert_eq!(bound_owner.as_str(), owner); + } + } + + #[tokio::test] + async fn create_start_preflight_rejects_a_community_switch_without_a_caller_pin() { + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some("wss://clicked.example".into()); + let error = scoped_local_preflight(&state, None, None, async { + tokio::task::yield_now().await; + *state.relay_url_override.lock().unwrap() = Some("wss://other.example".into()); + Ok(()) + }) + .await + .unwrap_err(); + assert!(error.contains("active community changed"), "{error}"); + } + + #[tokio::test] + async fn create_start_preflight_rejects_an_owner_switch_without_a_caller_pin() { + let state = crate::app_state::build_app_state(); + let error = scoped_local_preflight(&state, None, None, async { + tokio::task::yield_now().await; + *state.keys.lock().unwrap() = Keys::generate(); + Ok(()) + }) + .await + .unwrap_err(); + assert!(error.contains("active identity changed"), "{error}"); + } + + #[tokio::test] + async fn scoped_start_rejects_stale_scope_before_polling_preflight() { + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some("wss://other.example".into()); + let error = scoped_local_preflight(&state, Some("wss://clicked.example"), None, async { + panic!("stale Start must not poll mesh preflight"); + }) + .await + .unwrap_err(); + assert!(error.contains("active community changed"), "{error}"); + } + + // This exercises the production continuation (including its WS getter), + // not just the generic relay helper. State is ephemeral: no app setup, + // identity resolution, keyring, files, network or child process is used. + #[test] + fn first_start_continuation_accepts_unchanged_ws_and_wss_communities() { + let state = crate::app_state::build_app_state(); + let owner = workspace_owner_hex(&state).unwrap(); + for relay in ["ws://localhost:3000", "wss://community.example"] { + *state.relay_url_override.lock().unwrap() = Some(relay.to_owned()); + revalidate_first_start_scope(&state, relay, &owner).unwrap(); + } + } + + #[test] + fn first_start_continuation_rejects_community_changed_during_recovery() { + let state = crate::app_state::build_app_state(); + let owner = workspace_owner_hex(&state).unwrap(); + *state.relay_url_override.lock().unwrap() = Some("wss://other.example".to_owned()); + let error = + revalidate_first_start_scope(&state, "wss://community.example", &owner).unwrap_err(); + assert!(error.contains("active community changed"), "{error}"); + } + + #[test] + fn first_start_continuation_rejects_owner_changed_during_recovery() { + let state = crate::app_state::build_app_state(); + let owner = workspace_owner_hex(&state).unwrap(); + *state.relay_url_override.lock().unwrap() = Some("wss://community.example".to_owned()); + *state.keys.lock().unwrap() = Keys::generate(); + let error = + revalidate_first_start_scope(&state, "wss://community.example", &owner).unwrap_err(); + assert!(error.contains("active identity changed"), "{error}"); + } +} diff --git a/desktop/src-tauri/src/commands/agents_stop.rs b/desktop/src-tauri/src/commands/agents_stop.rs new file mode 100644 index 00000000000..a587b11c893 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_stop.rs @@ -0,0 +1,35 @@ +//! Ordinary selected-generation Stop, shared with pair controls. +use super::*; + +#[tauri::command] +pub async fn stop_managed_agent( + pubkey: String, + selected_run_id: Option, + expected_relay_url: Option, + app: AppHandle, +) -> Result { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let relay = expected_relay_url + .ok_or("Exact Stop unsupported without selected community; refresh runtime status")?; + crate::managed_agents::stop_managed_agent_runtime( + pubkey.clone(), + relay, + selected_run_id, + app.clone(), + )?; + let state = app.state::(); + let records = load_managed_agents(&app)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("agent not found")?; + summarize_from_disk(&app, record, &runtimes) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} diff --git a/desktop/src-tauri/src/commands/host_execution.rs b/desktop/src-tauri/src/commands/host_execution.rs new file mode 100644 index 00000000000..c1ab8233376 --- /dev/null +++ b/desktop/src-tauri/src/commands/host_execution.rs @@ -0,0 +1,158 @@ +//! Native executor entry point. Transport subscription/publication and the host +//! picker remain gated off; a relay ACK is never a launch result. No host-key-only +//! connection is created: every query uses the existing active owner authority. +use crate::{ + app_state::AppState, + managed_agents::{self, AcpAvailabilityStatus, AuthStatus}, +}; +use buzz_core_pkg::host_execution::{self, Action, Receipt}; +use nostr::{Event, JsonUtil, Timestamp}; +use tauri::{AppHandle, State}; + +/// Inspect the destination's own provisioned configuration. Only an opaque +/// revision and Rust catalog ID cross IPC; no key, env, path or OS hostname. +#[tauri::command] +pub async fn inspect_local_execution_config( + app: AppHandle, + state: State<'_, AppState>, + expected_owner: String, + expected_relay: String, + agent: String, +) -> Result { + super::hosts::owner_keys(&state, &expected_owner)?; + crate::relay::assert_expected_relay_scope( + Some(&expected_relay), + &crate::relay::relay_api_base_url_with_override(&state), + )?; + let captured_owner = expected_owner.clone(); + let result = tokio::task::spawn_blocking(move || { + let records = managed_agents::load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == agent) + .ok_or("agent is not provisioned on this executor")?; + managed_agents::execution_agent_owner(record, &captured_owner)?; + serde_json::to_value(managed_agents::local_execution_config(&app, record)?) + .map_err(|_| "cannot serialize execution config".into()) + }) + .await + .map_err(|_| "execution config task failed")?; + super::hosts::owner_keys(&state, &expected_owner)?; + crate::relay::assert_expected_relay_scope( + Some(&expected_relay), + &crate::relay::relay_api_base_url_with_override(&state), + )?; + result +} + +/// Execute an owner-signed, destination-encrypted request using only this +/// Desktop's own approved configuration. Fresh registration lookup is mandatory +/// even on retry. An unreachable/deleted registration blocks work, not merely UI. +#[tauri::command] +pub async fn execute_host_command( + app: AppHandle, + state: State<'_, AppState>, + expected_owner: String, + expected_relay: String, + event: serde_json::Value, +) -> Result { + let owner = super::hosts::owner_keys(&state, &expected_owner)?; + let relay = buzz_core_pkg::relay::normalize_relay_url(&expected_relay) + .map_err(|_| "invalid execution relay")?; + crate::relay::assert_expected_relay_scope( + Some(&relay), + &crate::relay::relay_api_base_url_with_override(&state), + )?; + let event = Event::from_json(event.to_string()).map_err(|_| "invalid execution event")?; + buzz_core_pkg::verify_event(&event).map_err(|_| "invalid execution signature")?; + if event.pubkey != owner.public_key() + || event.kind.as_u16() as u32 != buzz_core_pkg::kind::KIND_HOST_COMMAND + { + return Err("foreign execution command".into()); + } + let registrations: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().is_some_and(|s| s == "e")) + .collect(); + let [registration_tag] = registrations.as_slice() else { + return Err("invalid execution registration reference".into()); + }; + let tag = registration_tag.as_slice(); + if tag.len() != 2 || !host_execution::hex_id(&tag[1], 64) { + return Err("invalid execution registration reference".into()); + } + let registration_id = tag[1].clone(); + // Discovery happens before the final authority read, not after a registration + // cached during a potentially long-running CLI probe. Diagnostics stay native. + let catalog = super::discover_acp_providers(app.clone(), Some(true)) + .await + .unwrap_or_default(); + let registrations = crate::relay::query_private_host_at_with_keys(&state, + &crate::relay::relay_http_base_url(&relay), + &[serde_json::json!({"kinds": [50000], "ids": [registration_id], "#p": [expected_owner], "limit": 2})], + &owner, None).await.map_err(|_| "cannot revalidate execution registration")?; + let [registration] = registrations.as_slice() else { + return Err("execution registration is absent or revoked".into()); + }; + if registration.id.to_hex() != registration_id { + return Err("wrong execution registration returned".into()); + } + let host = super::hosts::host_keys(&owner)?; + let request = host_execution::decrypt_command( + &host, + registration, + &event, + &relay, + // Authenticate historical bytes for read-only journal recovery. The + // native transition checks the actual wall clock AFTER immutable replay + // and BEFORE any new intent/side effect. + event.created_at.as_secs(), + )?; + if event.created_at.as_secs() > Timestamp::now().as_secs().saturating_add(30) { + return Err("execution command timestamp is in the future".into()); + } + let compatible_runtime = match &request.action { + Action::Start { runtime, .. } => catalog.iter().any(|entry| { + entry.id == *runtime + && entry.availability == AcpAvailabilityStatus::Available + && matches!( + entry.auth_status, + AuthStatus::LoggedIn | AuthStatus::NotApplicable + ) + }), + Action::Stop { .. } => true, + }; + // Bind the identity/relay again after the awaited query. The transition uses + // these exact checked inputs and rechecks while holding the runtime locks. + super::hosts::owner_keys(&state, &expected_owner)?; + crate::relay::assert_expected_relay_scope( + Some(&relay), + &crate::relay::relay_api_base_url_with_override(&state), + )?; + let registration = registration.clone(); + let result = tokio::task::spawn_blocking(move || { + let entry = managed_agents::execute_host_operation( + &app, + &expected_owner, + &event.id.to_hex(), + &request, + compatible_runtime, + )?; + let result = Receipt { + v: 1, + command: entry.command_id, + run: entry.request.run().into(), + request: entry.request, + outcome: entry.outcome, + observed_at: entry.observed_at, + }; + // Encrypted host-signed observation; no raw process error text escapes. + let event = + host_execution::receipt(&host, ®istration, &result, Timestamp::now().as_secs())?; + serde_json::to_value(event).map_err(|_| "cannot serialize execution receipt".into()) + }) + .await + .map_err(|_| "execution task failed; outcome unknown")?; + result +} diff --git a/desktop/src-tauri/src/commands/host_move.rs b/desktop/src-tauri/src/commands/host_move.rs new file mode 100644 index 00000000000..cff1a54bab6 --- /dev/null +++ b/desktop/src-tauri/src/commands/host_move.rs @@ -0,0 +1,553 @@ +//! Selected-run Move is a dependency in the existing Start outbox, not another +//! execution ledger. Only a verified Stopped receipt releases the destination. +use super::host_start::{current_attempt, current_registration, history, request, scope}; +use super::host_start_store::{Pending, Store}; +use crate::app_state::AppState; +use buzz_core_pkg::{ + host, + host_execution::{self, Action, Command, Outcome}, +}; +use nostr::{Event, JsonUtil, Keys, PublicKey, Timestamp}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, State}; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct MoveIntent { + stop: String, + // A signed but NEVER published Start template. Its operation ID is reserved; + // its TTL is refreshed once, on release, before persistence/publication. + destination: Pending, + // Domain-separated owner signature binds the two commands. Swapping a + // destination or borrowing another Move's Stop cannot authorize release. + authorization: String, + start: Option, + error: Option, +} + +fn authorization(stop: &str, destination: &Pending) -> nostr::secp256k1::Message { + nostr::secp256k1::Message::from_digest( + Sha256::digest(format!( + "buzz.host.move.v1\n{stop}\n{}\n{}", + destination.command.id, + destination.supersedes.as_deref().unwrap_or("") + )) + .into(), + ) +} + +fn pending(owner: &Keys, registration: Event, req: &Command, now: u64) -> Result { + Ok(Pending { + command: host_execution::command(owner, ®istration, req, now)?, + registration, + receipt: None, + published: false, + error: None, + supersedes: None, + }) +} + +fn outcome(owner: &Keys, pending: &Pending, relay: &str) -> Result, String> { + let req = request(owner, pending, relay)?; + pending + .receipt + .as_ref() + .map(|receipt| { + host_execution::decrypt_receipt( + owner, + &pending.registration, + receipt, + &pending.command, + &req, + ) + .map(|r| r.outcome) + }) + .transpose() +} + +pub(super) fn validate_moves(store: &Store, owner: &Keys, relay: &str) -> Result<(), String> { + let mut sources = std::collections::HashSet::new(); + let mut reservations = std::collections::HashSet::new(); + for (id, intent) in &store.journal.moves { + let stop = store + .journal + .sent + .get(&intent.stop) + .ok_or("Move Stop missing")?; + let src = request(owner, stop, relay)?; + let dst = request(owner, &intent.destination, relay)?; + let source_host = host::validate(&stop.registration)?.host; + let destination_host = host::validate(&intent.destination.registration)?.host; + if id != &src.operation + || !matches!(src.action, Action::Stop { .. }) + || !matches!(dst.action, Action::Start { .. }) + || src.agent != dst.agent + || source_host == destination_host + || src.operation == dst.operation + || intent.destination.receipt.is_some() + || intent.destination.published + || !sources.insert((src.agent.clone(), source_host, src.run().to_owned())) + { + return Err("Move binding corrupt".into()); + } + let sig = intent + .authorization + .parse::() + .map_err(|_| "Move authorization invalid")?; + let key = owner + .public_key() + .xonly() + .map_err(|_| "Move owner invalid")?; + nostr::SECP256K1 + .verify_schnorr( + &sig, + &authorization(&intent.stop, &intent.destination), + &key, + ) + .map_err(|_| "Move authorization invalid")?; + if let Some(start) = &intent.start { + let released = store.journal.sent.get(start).ok_or("Move Start missing")?; + let req = request(owner, released, relay)?; + if outcome(owner, stop, relay)? != Some(Outcome::Stopped) + || req.operation != dst.operation + || req.agent != dst.agent + || req.action != dst.action + || released.registration != intent.destination.registration + || released.supersedes != intent.destination.supersedes + { + return Err("Move released without exact confirmed Stop".into()); + } + } else if !reservations.insert((dst.agent.clone(), destination_host)) { + return Err("Move destination has competing reservations".into()); + } + } + Ok(()) +} + +pub(super) fn check_reservation( + store: &Store, + owner: &Keys, + relay: &str, + agent: &str, + host_key: PublicKey, +) -> Result<(), String> { + for intent in store.journal.moves.values().filter(|m| m.start.is_none()) { + if request(owner, &intent.destination, relay)?.agent == agent + && host::validate(&intent.destination.registration)?.host == host_key + { + return Err("Destination reserved by Move; confirmed source Stop is required".into()); + } + } + Ok(()) +} + +// Advisory availability is re-read natively before Stop and again before Start. +// The actual executor independently revalidates configuration at the spawn seam. +async fn destination_config( + state: &AppState, + owner: &Keys, + relay: &str, + reg: &Event, + req: &Command, +) -> Result<(), String> { + let Action::Start { runtime, revision } = &req.action else { + return Err("invalid Move destination".into()); + }; + let host_key = host::validate(reg)?.host; + let mut events = history( + state, + owner, + relay, + serde_json::json!({ + "kinds":[50000], "authors":[host_key.to_hex()], "#p":[owner.public_key().to_hex()], + "#e":[reg.id.to_hex()], "#l":["profile"], "limit":1000 + }), + ) + .await?; + events.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + let event = events + .first() + .ok_or("Destination capability profile unavailable")?; + let report = host::decrypt_report(owner, reg, event)?; + if !report.accepts_start + || !report + .provisioned + .iter() + .any(|c| c.agent == req.agent && c.runtime == *runtime && c.revision == *revision) + { + return Err( + "Destination setup changed or Start receiver unavailable; source is not restarted" + .into(), + ); + } + Ok(()) +} + +/// Persist one owner-authorized Move and exact-run Stop before publication. A +/// repeated click/restart reuses that intent; a different destination is not a retry. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn queue_host_move( + app: AppHandle, + state: State<'_, AppState>, + expected_owner: String, + expected_relay: String, + source_registration: serde_json::Value, + run: String, + destination_registration: serde_json::Value, + agent: String, + runtime: String, + revision: String, +) -> Result { + super::host_start::require_preview()?; + let (owner, relay) = scope(&state, &expected_owner, &expected_relay)?; + let source = + Event::from_json(source_registration.to_string()).map_err(|_| "invalid Move source")?; + let destination = Event::from_json(destination_registration.to_string()) + .map_err(|_| "invalid Move destination")?; + let mut store = Store::open(&app, &expected_owner, &relay)?; + super::host_start::validate_journal(&store, &owner, &relay)?; + // Retry before availability reads: unavailable/revoked hosts must not erase intent. + let existing = store.journal.moves.iter().find_map(|(id, intent)| { + let stop = store.journal.sent.get(&intent.stop)?; + let src = request(&owner, stop, &relay).ok()?; + (src.agent == agent + && src.run() == run + && host::validate(&stop.registration).ok()?.host == host::validate(&source).ok()?.host) + .then(|| (id.clone(), intent.clone())) + }); + if let Some((id, mut intent)) = existing { + if intent.destination.registration.id != destination.id { + return Err("This run already has a saved Move to another destination".into()); + } + // An explicit retry after proven Stop may adopt newly provisioned config. + // Unknown Stop never changes its planned destination or creates a Start. + let stop = store + .journal + .sent + .get(&intent.stop) + .ok_or("Move Stop missing")?; + if intent.start.is_none() && outcome(&owner, stop, &relay)? == Some(Outcome::Stopped) { + let reg = + current_registration(&state, &owner, &relay, &destination.id.to_hex()).await?; + let mut req = request(&owner, &intent.destination, &relay)?; + if req.action + != (Action::Start { + runtime: runtime.clone(), + revision: revision.clone(), + }) + { + req.action = Action::Start { runtime, revision }; + let now = Timestamp::now().as_secs(); + req.expires_at = now + host_execution::COMMAND_TTL; + destination_config(&state, &owner, &relay, ®, &req).await?; + let mut updated = pending(&owner, reg, &req, now)?; + updated.supersedes = intent.destination.supersedes.clone(); + intent.authorization = owner + .sign_schnorr(&authorization(&intent.stop, &updated)) + .to_string(); + intent.destination = updated; + intent.error = None; + scope(&state, &expected_owner, &relay)?; + store.journal.moves.insert(id.clone(), intent); + validate_moves(&store, &owner, &relay)?; + store.save()?; + } + } + return Ok(id); + } + let source = current_registration(&state, &owner, &relay, &source.id.to_hex()).await?; + let destination = + current_registration(&state, &owner, &relay, &destination.id.to_hex()).await?; + let destination_host = host::validate(&destination)?.host; + let source_host = host::validate(&source)?.host.to_hex(); + let runs = super::get_presence_runs( + state.clone(), + expected_owner.clone(), + relay.clone(), + vec![agent.clone(), destination_host.to_hex()], + ) + .await?; + let now = Timestamp::now().as_secs(); + if !runs.get(&agent).is_some_and(|runs| { + runs.iter().any(|r| { + r.run == run + && r.status != "offline" + && r.expires_at > now + && r.location.as_ref().is_some_and(|l| l.host == source_host) + }) + }) { + return Err("Selected active instance changed; refresh before Move".into()); + } + if host::validate(&source)?.host == destination_host { + return Err("Choose a different host".into()); + } + if !runs.get(&destination_host.to_hex()).is_some_and(|runs| { + runs.iter() + .any(|r| r.status != "offline" && r.expires_at > now) + }) { + return Err("Destination availability unconfirmed; source was not stopped".into()); + } + if runs.get(&agent).is_some_and(|runs| { + runs.iter().any(|r| { + r.status != "offline" + && r.expires_at > now + && r.location + .as_ref() + .is_some_and(|l| l.host == destination_host.to_hex()) + }) + }) { + return Err( + "Agent already has an active destination instance; source was not stopped".into(), + ); + } + check_reservation(&store, &owner, &relay, &agent, destination_host)?; + // A destination with an unresolved prior Start is not silently adopted. + let supersedes = if let Some(previous) = + current_attempt(&store, &owner, &relay, &agent, destination_host)? + { + let req = request(&owner, previous, &relay)?; + if outcome(&owner, previous, &relay)? != Some(Outcome::Rejected) + && !super::host_start::confirmed_stop(&state, &owner, &relay, previous, &req).await? + { + return Err("Destination has an unresolved Start; reconcile it before Move".into()); + } + Some(previous.command.id.to_hex()) + } else { + None + }; + let now = Timestamp::now().as_secs(); + let stop_req = Command { + v: 1, + operation: uuid::Uuid::new_v4().simple().to_string(), + relay: relay.clone(), + agent: agent.clone(), + expires_at: now + host_execution::COMMAND_TTL, + action: Action::Stop { run }, + }; + let start_req = Command { + v: 1, + operation: uuid::Uuid::new_v4().simple().to_string(), + relay: relay.clone(), + agent, + expires_at: now + host_execution::COMMAND_TTL, + action: Action::Start { runtime, revision }, + }; + let stop = pending(&owner, source, &stop_req, now)?; + let mut destination = pending(&owner, destination, &start_req, now)?; + destination.supersedes = supersedes; + destination_config( + &state, + &owner, + &relay, + &destination.registration, + &start_req, + ) + .await?; + scope(&state, &expected_owner, &relay)?; + let id = stop.command.id.to_hex(); + let intent = MoveIntent { + authorization: owner + .sign_schnorr(&authorization(&id, &destination)) + .to_string(), + stop: id.clone(), + destination, + start: None, + error: None, + }; + store.journal.sent.insert(id, stop); + store + .journal + .moves + .insert(stop_req.operation.clone(), intent); + validate_moves(&store, &owner, &relay)?; + store.save()?; + Ok(stop_req.operation) +} + +pub(super) async fn advance_moves( + state: &AppState, + owner: &Keys, + relay: &str, + store: &mut Store, +) -> Result<(), String> { + let ids: Vec<_> = store.journal.moves.keys().cloned().collect(); + for id in ids { + let intent = store.journal.moves.get(&id).ok_or("Move missing")?.clone(); + if intent.start.is_some() { + continue; + } + let stop = store + .journal + .sent + .get(&intent.stop) + .ok_or("Move Stop missing")?; + if outcome(owner, stop, relay)? != Some(Outcome::Stopped) { + continue; + } + let result = release(state, owner, relay, store, &id, &intent).await; + if let Err(error) = result { + store + .journal + .moves + .get_mut(&id) + .ok_or("Move missing")? + .error = Some(error); + } + store.save()?; + } + Ok(()) +} + +async fn release( + state: &AppState, + owner: &Keys, + relay: &str, + store: &mut Store, + id: &str, + intent: &MoveIntent, +) -> Result<(), String> { + // Revocation on either leg blocks new side effects even after a saved receipt. + let stop = store + .journal + .sent + .get(&intent.stop) + .ok_or("Move Stop missing")?; + current_registration(state, owner, relay, &stop.registration.id.to_hex()).await?; + current_registration( + state, + owner, + relay, + &intent.destination.registration.id.to_hex(), + ) + .await?; + let req = request(owner, &intent.destination, relay)?; + destination_config(state, owner, relay, &intent.destination.registration, &req).await?; + scope(state, &owner.public_key().to_hex(), relay)?; + release_to_outbox(store, owner, relay, id, intent, Timestamp::now().as_secs()) +} + +fn release_to_outbox( + store: &mut Store, + owner: &Keys, + relay: &str, + id: &str, + intent: &MoveIntent, + now: u64, +) -> Result<(), String> { + let stop = store + .journal + .sent + .get(&intent.stop) + .ok_or("Move Stop missing")?; + if intent.start.is_some() || outcome(owner, stop, relay)? != Some(Outcome::Stopped) { + return Err("Move requires exact confirmed Stop before release".into()); + } + let mut req = request(owner, &intent.destination, relay)?; + req.expires_at = now + host_execution::COMMAND_TTL; + let mut start = pending(owner, intent.destination.registration.clone(), &req, now)?; + start.supersedes = intent.destination.supersedes.clone(); + let start_id = start.command.id.to_hex(); + store.journal.sent.insert(start_id.clone(), start); + let entry = store.journal.moves.get_mut(id).ok_or("Move missing")?; + entry.start = Some(start_id); + entry.error = None; + // The caller persists BOTH release and outbox before retry_pending publishes. + validate_moves(store, owner, relay) +} + +#[derive(Serialize)] +pub(super) struct MoveProgress { + operation: String, + agent: String, + source_host: String, + source_run: String, + destination_host: String, + destination_run: String, + status: String, + error: Option, +} + +pub(super) fn progress( + store: &Store, + owner: &Keys, + relay: &str, +) -> Result, String> { + store + .journal + .moves + .iter() + .map(|(id, intent)| { + let stop = store + .journal + .sent + .get(&intent.stop) + .ok_or("Move Stop missing")?; + let src = request(owner, stop, relay)?; + let dst = request(owner, &intent.destination, relay)?; + let host_key = host::validate(&intent.destination.registration)?.host; + // Destination recovery uses the ordinary explicit Start supersession chain. + let current = if intent.start.is_some() { + current_attempt(store, owner, relay, &dst.agent, host_key)? + } else { + None + }; + let result = current + .map(|p| outcome(owner, p, relay)) + .transpose()? + .flatten(); + let stopped = outcome(owner, stop, relay)? == Some(Outcome::Stopped); + let status = match (stopped, intent.start.is_some(), result) { + (false, _, _) + if stop.receipt.is_some() || src.expires_at <= Timestamp::now().as_secs() => + { + "stop_unconfirmed" + } + (false, _, _) => "stopping", + (true, false, _) => "stopped_waiting_destination", + (true, true, Some(Outcome::Rejected)) => "stopped_start_rejected", + (true, true, Some(Outcome::Spawned)) => "destination_spawned", + (true, true, Some(Outcome::Listening)) => "destination_listening", + (true, true, Some(Outcome::Ready)) => "destination_ready", + (true, true, Some(_)) => "stopped_start_unknown", + (true, true, None) => { + if current + .map(|p| request(owner, p, relay)) + .transpose()? + .is_some_and(|r| r.expires_at <= Timestamp::now().as_secs()) + { + "stopped_start_unknown" + } else { + "starting" + } + } + }; + Ok(MoveProgress { + operation: id.clone(), + agent: src.agent.clone(), + source_host: host::validate(&stop.registration)?.host.to_hex(), + source_run: src.run().into(), + destination_host: host_key.to_hex(), + destination_run: current + .map(|p| request(owner, p, relay)) + .transpose()? + .map_or(dst.operation, |r| r.operation), + status: status.into(), + error: intent + .error + .clone() + .or_else(|| stop.error.clone()) + .or_else(|| current.and_then(|p| p.error.clone())), + }) + }) + .collect() +} + +#[cfg(test)] +#[path = "host_move_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/host_move_tests.rs b/desktop/src-tauri/src/commands/host_move_tests.rs new file mode 100644 index 00000000000..42fe37dce95 --- /dev/null +++ b/desktop/src-tauri/src/commands/host_move_tests.rs @@ -0,0 +1,271 @@ +use super::*; +use buzz_core_pkg::host_execution::Receipt; + +struct Fixture { + dir: tempfile::TempDir, + owner: Keys, + source: Keys, + destination: Keys, + store: Store, + id: String, +} +const RELAY: &str = "wss://relay.example"; +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let owner = Keys::generate(); + let source = Keys::generate(); + let destination = Keys::generate(); + let mut store = Store::open_dir(dir.path(), &owner.public_key().to_hex(), RELAY).unwrap(); + let agent = Keys::generate().public_key().to_hex(); + let stop_req = Command { + v: 1, + operation: "ab".repeat(16), + relay: RELAY.into(), + agent: agent.clone(), + expires_at: 400, + action: Action::Stop { + run: "cd".repeat(16), + }, + }; + let start_req = Command { + v: 1, + operation: "ef".repeat(16), + relay: RELAY.into(), + agent, + expires_at: 400, + action: Action::Start { + runtime: "buzz-agent".into(), + revision: "aa".repeat(32), + }, + }; + let stop = pending( + &owner, + host::registration(&owner, source.public_key(), 100).unwrap(), + &stop_req, + 100, + ) + .unwrap(); + let destination_pending = pending( + &owner, + host::registration(&owner, destination.public_key(), 100).unwrap(), + &start_req, + 100, + ) + .unwrap(); + let stop_id = stop.command.id.to_hex(); + let intent = MoveIntent { + authorization: owner + .sign_schnorr(&authorization(&stop_id, &destination_pending)) + .to_string(), + stop: stop_id.clone(), + destination: destination_pending, + start: None, + error: None, + }; + store.journal.sent.insert(stop_id, stop); + store + .journal + .moves + .insert(stop_req.operation.clone(), intent); + Self { + dir, + owner, + source, + destination, + store, + id: stop_req.operation, + } + } + fn receipt(&mut self, result: Outcome) { + let stop_id = self.store.journal.moves[&self.id].stop.clone(); + let stop = self.store.journal.sent.get_mut(&stop_id).unwrap(); + let req = request(&self.owner, stop, RELAY).unwrap(); + let receipt = Receipt { + v: 1, + command: stop_id, + run: req.run().into(), + request: req, + observed_at: 110, + outcome: result, + }; + stop.receipt = + Some(host_execution::receipt(&self.source, &stop.registration, &receipt, 111).unwrap()); + } + fn release(&mut self) -> Result<(), String> { + let intent = self.store.journal.moves[&self.id].clone(); + release_to_outbox(&mut self.store, &self.owner, RELAY, &self.id, &intent, 500) + } +} + +#[test] +fn only_authoritative_stopped_releases_same_agent_new_generation() { + for result in [ + None, + Some(Outcome::Accepted), + Some(Outcome::RootExited), + Some(Outcome::Unknown), + Some(Outcome::Rejected), + ] { + let mut f = Fixture::new(); + if let Some(result) = result { + f.receipt(result); + } + super::super::host_start::validate_journal(&f.store, &f.owner, RELAY).unwrap(); + assert!(f.release().is_err()); + assert_eq!(f.store.journal.sent.len(), 1); + assert_eq!( + progress(&f.store, &f.owner, RELAY).unwrap()[0].status, + "stop_unconfirmed" + ); + } + let mut f = Fixture::new(); + f.receipt(Outcome::Stopped); + f.release().unwrap(); + let intent = &f.store.journal.moves[&f.id]; + let src = request(&f.owner, &f.store.journal.sent[&intent.stop], RELAY).unwrap(); + let dst = request( + &f.owner, + &f.store.journal.sent[intent.start.as_ref().unwrap()], + RELAY, + ) + .unwrap(); + assert_eq!(src.agent, dst.agent); + assert_ne!(src.run(), dst.run()); + assert_eq!( + dst.expires_at, 800, + "TTL begins at release, not Stop queue time" + ); + assert_eq!(f.store.journal.sent.len(), 2); + assert!( + f.release().is_err(), + "second release never changes command bytes" + ); + super::super::host_start::validate_journal(&f.store, &f.owner, RELAY).unwrap(); +} + +#[test] +fn restart_ack_loss_and_peer_survive_without_duplicate_or_global_fence() { + let mut f = Fixture::new(); + let template = f.store.journal.moves[&f.id].destination.clone(); + let req = request(&f.owner, &template, RELAY).unwrap(); + assert!(check_reservation( + &f.store, + &f.owner, + RELAY, + &req.agent, + f.destination.public_key() + ) + .is_err()); + assert!( + check_reservation(&f.store, &f.owner, RELAY, &req.agent, f.source.public_key()).is_ok() + ); + let mut peer_req = req.clone(); + peer_req.agent = Keys::generate().public_key().to_hex(); + peer_req.operation = "12".repeat(16); + let peer = pending(&f.owner, template.registration.clone(), &peer_req, 100).unwrap(); + let peer_id = peer.command.id.to_hex(); + let peer_bytes = peer.command.as_json(); + f.store.journal.sent.insert(peer_id.clone(), peer); + f.store.save().unwrap(); + drop(f.store); + f.store = Store::open_dir(f.dir.path(), &f.owner.public_key().to_hex(), RELAY).unwrap(); + assert!(f.release().is_err()); + f.receipt(Outcome::Stopped); + f.release().unwrap(); + f.store.save().unwrap(); + let start_id = f.store.journal.moves[&f.id].start.clone().unwrap(); + let bytes = f.store.journal.sent[&start_id].command.as_json(); + drop(f.store); + f.store = Store::open_dir(f.dir.path(), &f.owner.public_key().to_hex(), RELAY).unwrap(); + assert!(f.release().is_err()); + assert_eq!(f.store.journal.sent[&start_id].command.as_json(), bytes); + assert_eq!(f.store.journal.sent[&peer_id].command.as_json(), peer_bytes); + assert_eq!(f.store.journal.sent.len(), 3); +} + +#[test] +fn stale_receipt_swapped_destination_wrong_scope_and_corrupt_release_fail_closed() { + let mut f = Fixture::new(); + f.receipt(Outcome::Stopped); + let intent = f.store.journal.moves[&f.id].clone(); + let stopped = f.store.journal.sent[&intent.stop].receipt.clone().unwrap(); + let other = Fixture::new(); + f.store.journal.sent.get_mut(&intent.stop).unwrap().receipt = other.store.journal.sent + [&other.store.journal.moves[&other.id].stop] + .receipt + .clone(); + assert!(f.release().is_err()); + f.store.journal.sent.get_mut(&intent.stop).unwrap().receipt = Some(stopped); + assert!(validate_moves(&f.store, &f.owner, "wss://foreign.example").is_err()); + assert!(validate_moves(&f.store, &Keys::generate(), RELAY).is_err()); + f.store.journal.moves.get_mut(&f.id).unwrap().destination = + other.store.journal.moves[&other.id].destination.clone(); + assert!(validate_moves(&f.store, &f.owner, RELAY).is_err()); + f.store.journal.moves.insert(f.id.clone(), intent); + f.release().unwrap(); + f.store + .journal + .sent + .get_mut(&f.store.journal.moves[&f.id].stop) + .unwrap() + .receipt = None; + assert!(validate_moves(&f.store, &f.owner, RELAY).is_err()); +} + +#[test] +fn destination_rejection_reports_source_stopped_never_moved_or_restarted() { + let mut f = Fixture::new(); + f.receipt(Outcome::Stopped); + f.release().unwrap(); + let start_id = f.store.journal.moves[&f.id].start.clone().unwrap(); + let start = f.store.journal.sent.get_mut(&start_id).unwrap(); + let req = request(&f.owner, start, RELAY).unwrap(); + let receipt = Receipt { + v: 1, + command: start_id, + run: req.run().into(), + request: req, + observed_at: 501, + outcome: Outcome::Rejected, + }; + start.receipt = + Some(host_execution::receipt(&f.destination, &start.registration, &receipt, 502).unwrap()); + assert_eq!( + progress(&f.store, &f.owner, RELAY).unwrap()[0].status, + "stopped_start_rejected" + ); + assert_eq!(f.store.journal.sent.len(), 2); + super::super::host_start::validate_journal(&f.store, &f.owner, RELAY).unwrap(); +} + +#[test] +fn renewed_stop_receipt_preserves_observation_and_releases_move_once() { + let mut f = Fixture::new(); + f.receipt(Outcome::Stopped); + let intent = f.store.journal.moves[&f.id].clone(); + let stop = f.store.journal.sent.get_mut(&intent.stop).unwrap(); + let command = stop.command.clone(); + let original = stop.receipt.clone().unwrap(); + super::super::host_start_store::prepare_receipt(stop, &f.owner, &f.source, RELAY, &[], 1200) + .unwrap(); + let renewed = stop.receipt.as_ref().unwrap(); + assert_ne!(renewed.id, original.id); + assert_eq!(renewed.content, original.content); + assert_eq!(renewed.tags, original.tags); + assert_eq!(stop.command, command); + let req = request(&f.owner, stop, RELAY).unwrap(); + let observation = + host_execution::decrypt_receipt(&f.owner, &stop.registration, renewed, &command, &req) + .unwrap(); + assert_eq!(observation.observed_at, 110); + assert_eq!(observation.outcome, Outcome::Stopped); + f.store.save().unwrap(); + drop(f.store); + f.store = Store::open_dir(f.dir.path(), &f.owner.public_key().to_hex(), RELAY).unwrap(); + release_to_outbox(&mut f.store, &f.owner, RELAY, &f.id, &intent, 1201).unwrap(); + let released = f.store.journal.moves[&f.id].clone(); + assert!(release_to_outbox(&mut f.store, &f.owner, RELAY, &f.id, &released, 1202).is_err()); + assert_eq!(f.store.journal.sent.len(), 2); + super::super::host_start::validate_journal(&f.store, &f.owner, RELAY).unwrap(); +} diff --git a/desktop/src-tauri/src/commands/host_start.rs b/desktop/src-tauri/src/commands/host_start.rs new file mode 100644 index 00000000000..68e48b3cd42 --- /dev/null +++ b/desktop/src-tauri/src/commands/host_start.rs @@ -0,0 +1,774 @@ +//! Owner-operated Desktop Start transport. No host-key login and no source +//! identity/config/file transfer. Command/receipt payloads are encrypted; routing +//! envelopes and local retry bookkeeping contain no launch secrets. +use super::host_start_store::{prepare_receipt, retry_pending, Pending, Store}; +use crate::app_state::AppState; +use buzz_core_pkg::{ + host, + host_execution::{self, Action, Command, Receipt}, +}; +use nostr::{Event, JsonUtil, Keys, Timestamp}; +use serde::Serialize; +use tauri::{AppHandle, Manager, State}; + +pub(super) fn scope(state: &AppState, owner: &str, relay: &str) -> Result<(Keys, String), String> { + let keys = super::hosts::owner_keys(state, owner)?; + let relay = + buzz_core_pkg::relay::normalize_relay_url(relay).map_err(|_| "invalid Start community")?; + crate::relay::assert_expected_relay_scope( + Some(&relay), + &crate::relay::relay_api_base_url_with_override(state), + )?; + Ok((keys, relay)) +} + +pub(super) async fn current_registration( + state: &AppState, + owner: &Keys, + relay: &str, + id: &str, +) -> Result { + let events = crate::relay::query_private_host_at_with_keys(state, &crate::relay::relay_http_base_url(relay), + &[serde_json::json!({"kinds":[50000], "ids":[id], "#p":[owner.public_key().to_hex()], "limit":2})], owner, None) + .await.map_err(|_| "cannot verify destination registration")?; + let [reg] = events.as_slice() else { + return Err("destination registration absent or revoked".into()); + }; + let binding = host::validate(reg)?; + if reg.id.to_hex() != id + || binding.label != "registration" + || binding.owner != owner.public_key() + { + return Err("destination registration mismatch".into()); + } + Ok(reg.clone()) +} + +pub(super) fn request(owner: &Keys, pending: &Pending, relay: &str) -> Result { + host_execution::validate_transport( + &pending.command, + &pending.registration, + owner.public_key(), + )?; + if pending.command.kind.as_u16() != 50001 { + return Err("invalid Start command".into()); + } + let binding = host::validate(&pending.registration)?; + let text = + nostr::nips::nip44::decrypt(owner.secret_key(), &binding.host, &pending.command.content) + .map_err(|_| "invalid Start ciphertext")?; + let req: Command = serde_json::from_str(&text).map_err(|_| "invalid Start request")?; + req.validate()?; + if req.relay != relay { + return Err("invalid Start scope".into()); + } + Ok(req) +} + +/// Persist a single immutable Start before any publication. Double click/restart +/// returns the existing attempt for that agent/destination, not another launch. +#[tauri::command] +#[allow(clippy::too_many_arguments)] // Explicit Tauri IPC fields; no opaque launch payload. +pub async fn queue_host_start( + app: AppHandle, + state: State<'_, AppState>, + expected_owner: String, + expected_relay: String, + registration: serde_json::Value, + agent: String, + runtime: String, + revision: String, + new_attempt_after: Option, +) -> Result { + require_preview()?; + let (owner, relay) = scope(&state, &expected_owner, &expected_relay)?; + let supplied = Event::from_json(registration.to_string()).map_err(|_| "invalid destination")?; + let reg = current_registration(&state, &owner, &relay, &supplied.id.to_hex()).await?; + scope(&state, &expected_owner, &relay)?; + let mut store = Store::open(&app, &expected_owner, &relay)?; + let host = host::validate(®)?.host; + validate_journal(&store, &owner, &relay)?; + super::host_move::check_reservation(&store, &owner, &relay, &agent, host)?; + let previous = current_attempt(&store, &owner, &relay, &agent, host)?; + let supersedes = if let Some(pending) = previous { + let req = request(&owner, pending, &relay)?; + if new_attempt_after.is_none() { + return Ok(req.operation); + } + if new_attempt_after.as_deref() != Some(req.operation.as_str()) { + return Err("Start intent changed; refresh before creating a new attempt".into()); + } + let rejected = pending + .receipt + .as_ref() + .map(|event| { + host_execution::decrypt_receipt( + &owner, + &pending.registration, + event, + &pending.command, + &req, + ) + }) + .transpose()? + .is_some_and(|r| r.outcome == host_execution::Outcome::Rejected); + if !rejected && !confirmed_stop(&state, &owner, &relay, pending, &req).await? { + return Err("New Start requires a signed confirmed Stop of the prior run; retry the saved operation instead".into()); + } + Some(pending.command.id.to_hex()) + } else { + if new_attempt_after.is_some() { + return Err("Prior Start intent not found".into()); + } + None + }; + scope(&state, &expected_owner, &relay)?; + let now = Timestamp::now().as_secs(); + let req = Command { + v: 1, + operation: uuid::Uuid::new_v4().simple().to_string(), + relay, + agent, + expires_at: now + host_execution::COMMAND_TTL, + action: Action::Start { runtime, revision }, + }; + let command = host_execution::command(&owner, ®, &req, now)?; + store.journal.sent.insert( + command.id.to_hex(), + Pending { + registration: reg, + command, + receipt: None, + published: false, + error: None, + supersedes, + }, + ); + store.save()?; + Ok(req.operation) +} + +/// Private progress view: a relay ACK is explicitly not workload readiness. +#[derive(Serialize)] +pub struct StartProgress { + operation: String, + created_at: u64, + current: bool, + action: String, + agent: String, + host: String, + run: String, + status: String, + error: Option, +} + +pub(super) async fn history( + state: &AppState, + owner: &Keys, + relay: &str, + mut filter: serde_json::Value, +) -> Result, String> { + let mut result = Vec::new(); + loop { + let page = crate::relay::query_private_host_at_with_keys( + state, + &crate::relay::relay_http_base_url(relay), + &[filter.clone()], + owner, + None, + ) + .await + .map_err(|_| "Start history unavailable")?; + if page.len() > 1000 || result.len() + page.len() > 4096 { + return Err("Start history requires archival".into()); + } + let mut page = page; + page.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + if page + .iter() + .any(|event| result.iter().any(|old: &Event| old.id == event.id)) + { + return Err("Start history cursor did not advance".into()); + } + let done = page.len() < 1000; + if let Some(last) = page.last() { + filter["until"] = last.created_at.as_secs().into(); + filter["before_id"] = last.id.to_hex().into(); + } + result.extend(page); + if done { + return Ok(result); + } + } +} + +async fn publish( + state: &AppState, + owner: &Keys, + relay: &str, + reg: &Event, + event: &Event, +) -> Result<(), String> { + host_execution::validate_transport(event, reg, owner.public_key())?; + let url = format!( + "{}/events", + crate::relay::relay_http_base_url(relay).trim_end_matches('/') + ); + let bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&bytes, "host Start transport")?; + let auth = crate::relay::build_nip98_auth_header_for_keys( + owner, + &reqwest::Method::POST, + &url, + &bytes, + )?; + let response = state + .media_fetch_client + .post(url) + .timeout(crate::relay::PRIVATE_HOST_REQUEST_TIMEOUT) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(bytes) + .send() + .await + .map_err(|_| "Start publication unconfirmed")?; + if !response.status().is_success() { + return Err("Start publication rejected or unconfirmed".into()); + } + let ack: crate::relay::SubmitEventResponse = crate::relay::parse_json_response(response) + .await + .map_err(|_| "invalid Start acknowledgement")?; + if !ack.accepted || ack.event_id != event.id.to_hex() { + return Err("Start publication unconfirmed".into()); + } + Ok(()) +} + +/// Recover origin outbox, receive destination commands, and publish exact signed +/// receipts. Called by the app-scoped receiver, serialized across windows/processes. +#[tauri::command] +pub async fn pump_host_start( + app: AppHandle, + state: State<'_, AppState>, + expected_owner: String, + expected_relay: String, +) -> Result { + if !cfg!(feature = "remote-start-preview") { + return Ok(StartSnapshot { + operations: vec![], + moves: vec![], + errors: vec![], + }); + } + let (owner, relay) = scope(&state, &expected_owner, &expected_relay)?; + let host_keys = super::hosts::host_keys(&owner)?; + let mut store = Store::open(&app, &expected_owner, &relay)?; + validate_journal(&store, &owner, &relay)?; + // Read confirmed results before sending again: lost ACK never mints a request. + let receipts = history( + &state, + &owner, + &relay, + serde_json::json!({"kinds":[50002], "#p":[expected_owner], "limit":1000}), + ) + .await?; + scope(&state, &expected_owner, &relay)?; + for pending in store.journal.sent.values_mut() { + let req = request(&owner, pending, &relay)?; + if pending.receipt.is_none() { + pending.receipt = receipts + .iter() + .find(|event| { + host_execution::decrypt_receipt( + &owner, + &pending.registration, + event, + &pending.command, + &req, + ) + .is_ok() + }) + .cloned(); + } + } + store.save()?; + super::host_move::advance_moves(&state, &owner, &relay, &mut store).await?; + let commands = history(&state, &owner, &relay, serde_json::json!({"kinds":[50001], "authors":[expected_owner], "#p":[expected_owner], "#x":[host_keys.public_key().to_hex()], "limit":1000})).await?; + let mut receiver_errors = Vec::new(); + for command in commands { + scope(&state, &expected_owner, &relay)?; + if store.journal.received.contains_key(&command.id.to_hex()) { + continue; + } + let Some(id) = command.tags.iter().find_map(|tag| { + let t = tag.as_slice(); + (t.len() == 2 && t[0] == "e").then(|| t[1].clone()) + }) else { + continue; + }; + let reg = match current_registration(&state, &owner, &relay, &id).await { + Ok(reg) => reg, + Err(error) => { + receiver_errors.push(error); + continue; + } + }; + let Ok(req) = host_execution::decrypt_command( + &host_keys, + ®, + &command, + &relay, + command.created_at.as_secs(), + ) else { + continue; + }; + // Both actions use the same exact-run native authority and signed receipts. + if store.journal.sent.len() + store.journal.received.len() + store.journal.moves.len() + >= 4096 + { + return Err("Start outbox requires archival".into()); + } + let value = serde_json::to_value(&command).map_err(|_| "invalid Start command")?; + let value = match super::execute_host_command( + app.clone(), + state.clone(), + expected_owner.clone(), + relay.clone(), + value, + ) + .await + { + Ok(value) => value, + Err(_) => { + receiver_errors.push( + "Destination execution unconfirmed; immutable request retained for retry" + .into(), + ); + continue; + } + }; + let receipt = + Event::from_json(value.to_string()).map_err(|_| "invalid native Start receipt")?; + host_execution::decrypt_receipt(&owner, ®, &receipt, &command, &req)?; + store.journal.received.insert( + command.id.to_hex(), + Pending { + registration: reg, + command, + receipt: Some(receipt), + published: false, + error: None, + supersedes: None, + }, + ); + store.save()?; + } + // The durable publication owner fsyncs each prepared envelope before send. + // One revoked/unpublishable operation must not starve another placement. + store + .retry_receipts( + |mut pending| { + let (state, owner, host_keys, relay, receipts, expected_owner) = ( + &state, + &owner, + &host_keys, + &relay, + &receipts, + &expected_owner, + ); + async move { + current_registration(state, owner, relay, &pending.registration.id.to_hex()) + .await?; + scope(state, expected_owner, relay)?; + prepare_receipt( + &mut pending, + owner, + host_keys, + relay, + receipts, + Timestamp::now().as_secs(), + )?; + Ok(pending) + } + }, + |pending| publish_pending(&state, &owner, &relay, pending, true), + ) + .await?; + retry_pending(&mut store.journal.sent, false, |pending| { + publish_pending(&state, &owner, &relay, pending, false) + }) + .await; + store.save()?; + scope(&state, &expected_owner, &relay)?; + app.manage(ReceiverHealth::default()); + if let Ok(mut health) = app.state::().0.lock() { + *health = Some((expected_owner, relay.clone(), std::time::Instant::now())); + } + let operations = store + .journal + .sent + .values() + .map(|pending| { + let req = request(&owner, pending, &relay)?; + let result: Option = pending + .receipt + .as_ref() + .map(|event| { + host_execution::decrypt_receipt( + &owner, + &pending.registration, + event, + &pending.command, + &req, + ) + }) + .transpose()?; + let status = match result { + Some(r) => serde_json::to_value(r.outcome) + .map_err(|_| "invalid outcome")? + .as_str() + .ok_or("invalid outcome")? + .to_string(), + None if req.expires_at <= Timestamp::now().as_secs() => "unknown".into(), + None if pending.published => "relay_accepted".into(), + None => "queued".into(), + }; + Ok(StartProgress { + action: if matches!(req.action, Action::Start { .. }) { + "start" + } else { + "stop" + } + .into(), + current: !store.journal.sent.values().any(|other| { + other.supersedes.as_deref() == Some(pending.command.id.to_hex().as_str()) + }), + created_at: pending.command.created_at.as_secs(), + host: host::validate(&pending.registration)?.host.to_hex(), + run: req.run().into(), + operation: req.operation, + agent: req.agent, + status, + error: pending.error.clone(), + }) + }) + .collect::, String>>()?; + let errors = store + .journal + .received + .values() + .filter_map(|pending| pending.error.clone()) + .chain(receiver_errors) + .collect(); + let moves = super::host_move::progress(&store, &owner, &relay)?; + Ok(StartSnapshot { + operations, + errors, + moves, + }) +} + +/// A preview build is intentionally required until real two-executor validation. +pub(super) fn require_preview() -> Result<(), String> { + if cfg!(feature = "remote-start-preview") { + Ok(()) + } else { + Err("Remote Start preview is not enabled in this build".into()) + } +} + +#[derive(Default)] +pub(super) struct ReceiverHealth(std::sync::Mutex>); + +pub(super) fn receiver_healthy(app: &AppHandle, owner: &str, relay: &str) -> bool { + cfg!(feature = "remote-start-preview") + && app.try_state::().is_some_and(|state| { + state.0.lock().is_ok_and(|health| { + health.as_ref().is_some_and(|(o, r, at)| { + o == owner && r == relay && at.elapsed() < std::time::Duration::from_secs(15) + }) + }) + }) +} + +/// Private transport snapshot. Per-operation failures never hide other outcomes. +#[derive(Serialize)] +pub struct StartSnapshot { + operations: Vec, + moves: Vec, + errors: Vec, +} + +pub(super) fn validate_journal(store: &Store, owner: &Keys, relay: &str) -> Result<(), String> { + let mut superseded = std::collections::HashSet::new(); + for (id, pending) in store.journal.sent.iter().chain(&store.journal.received) { + let req = request(owner, pending, relay)?; + if *id != pending.command.id.to_hex() { + return Err("Start outbox binding corrupt".into()); + } + if let Some(receipt) = &pending.receipt { + host_execution::decrypt_receipt( + owner, + &pending.registration, + receipt, + &pending.command, + &req, + )?; + } + if let Some(previous) = &pending.supersedes { + if !superseded.insert(previous) { + return Err("Start outbox has branched intents".into()); + } + let mut visited = std::collections::HashSet::new(); + let mut cursor = Some(id); + while let Some(next) = cursor { + if !visited.insert(next) { + return Err("Start outbox has cyclic intents".into()); + } + cursor = store + .journal + .sent + .get(next) + .and_then(|p| p.supersedes.as_ref()); + } + let old = store + .journal + .sent + .get(previous) + .ok_or("Start outbox predecessor missing")?; + let old_req = request(owner, old, relay)?; + if !matches!(req.action, Action::Start { .. }) + || !matches!(old_req.action, Action::Start { .. }) + || previous == id + || old_req.agent != req.agent + || host::validate(&old.registration)?.host + != host::validate(&pending.registration)?.host + { + return Err("Start outbox predecessor corrupt".into()); + } + } + } + if store + .journal + .received + .values() + .any(|pending| pending.receipt.is_none()) + { + return Err("Start outbox missing receipt".into()); + } + super::host_move::validate_moves(store, owner, relay)?; + Ok(()) +} + +pub(super) fn current_attempt<'a>( + store: &'a Store, + owner: &Keys, + relay: &str, + agent: &str, + host_key: nostr::PublicKey, +) -> Result, String> { + let mut current = None; + for (id, pending) in &store.journal.sent { + if !matches!(request(owner, pending, relay)?.action, Action::Start { .. }) + || request(owner, pending, relay)?.agent != agent + || host::validate(&pending.registration)?.host != host_key + { + continue; + } + if store + .journal + .sent + .values() + .any(|p| p.supersedes.as_ref() == Some(id)) + { + continue; + } + if current.replace(pending).is_some() { + return Err("Multiple prior Start intents require reconciliation".into()); + } + } + Ok(current) +} + +pub(super) async fn confirmed_stop( + state: &AppState, + owner: &Keys, + relay: &str, + pending: &Pending, + previous: &Command, +) -> Result { + let filter = |kind| serde_json::json!({"kinds":[kind], "#p":[owner.public_key().to_hex()], "#e":[pending.registration.id.to_hex()], "limit":1000}); + let commands = history(state, owner, relay, filter(50001)).await?; + let receipts = history(state, owner, relay, filter(50002)).await?; + for command in commands { + let candidate = Pending { + registration: pending.registration.clone(), + command, + receipt: None, + published: false, + error: None, + supersedes: None, + }; + let Ok(req) = request(owner, &candidate, relay) else { + continue; + }; + if req.agent != previous.agent + || !matches!(&req.action, Action::Stop { run } if run == previous.run()) + { + continue; + } + if receipts.iter().any(|event| { + host_execution::decrypt_receipt( + owner, + &candidate.registration, + event, + &candidate.command, + &req, + ) + .is_ok_and(|receipt| receipt.outcome == host_execution::Outcome::Stopped) + }) { + return Ok(true); + } + } + Ok(false) +} + +async fn publish_pending( + state: &AppState, + owner: &Keys, + relay: &str, + pending: Pending, + receipt: bool, +) -> Result<(), String> { + scope(state, &owner.public_key().to_hex(), relay)?; + let req = request(owner, &pending, relay)?; + let event = if receipt { + let event = pending + .receipt + .as_ref() + .ok_or("missing durable Start receipt")?; + host_execution::decrypt_receipt( + owner, + &pending.registration, + event, + &pending.command, + &req, + )?; + event + } else { + &pending.command + }; + current_registration(state, owner, relay, &pending.registration.id.to_hex()).await?; + scope(state, &owner.public_key().to_hex(), relay)?; + publish(state, owner, relay, &pending.registration, event).await +} + +#[cfg(test)] +mod tests { + use super::*; + fn pending(owner: &Keys, host: &Keys, operation: &str) -> Pending { + let registration = host::registration(owner, host.public_key(), 100).unwrap(); + let req = Command { + v: 1, + operation: operation.repeat(16), + relay: "wss://relay.example".into(), + agent: owner.public_key().to_hex(), + expires_at: 400, + action: Action::Start { + runtime: "goose".into(), + revision: "ab".repeat(32), + }, + }; + let command = host_execution::command(owner, ®istration, &req, 100).unwrap(); + Pending { + registration, + command, + receipt: None, + published: false, + error: None, + supersedes: None, + } + } + + #[tokio::test] + async fn start_and_receipt_block_key_backup_before_network() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("ws://{}", listener.local_addr().unwrap()); + let owner = Keys::generate(); + let host = Keys::generate(); + let pending = pending(&owner, &host, "ab"); + let state = crate::app_state::build_app_state(); + for (kind, signer) in [(50001, &owner), (50002, &host)] { + let injected = nostr::EventBuilder::new( + nostr::Kind::Custom(kind), + "ncryptsec1synthetic-backup-injection", + ) + .allow_self_tagging() + .tags(pending.command.tags.clone()) + .sign_with_keys(signer) + .unwrap(); + let error = publish(&state, &owner, &relay, &pending.registration, &injected) + .await + .unwrap_err(); + assert!(error.contains("blocked host Start transport")); + } + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); + } + + #[test] + fn immutable_intent_chain_scoped_and_corruption_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let owner = Keys::generate(); + let host = Keys::generate(); + let relay = "wss://relay.example"; + let mut store = Store::open_dir(dir.path(), &owner.public_key().to_hex(), relay).unwrap(); + let first = pending(&owner, &host, "ab"); + let first_id = first.command.id.to_hex(); + store.journal.sent.insert(first_id.clone(), first); + validate_journal(&store, &owner, relay).unwrap(); + assert!(validate_journal(&store, &owner, "wss://foreign.example").is_err()); + assert!(validate_journal(&store, &Keys::generate(), relay).is_err()); + let mut second = pending(&owner, &host, "cd"); + let second_id = second.command.id.to_hex(); + second.supersedes = Some(first_id.clone()); + store.journal.sent.insert(second_id.clone(), second); + validate_journal(&store, &owner, relay).unwrap(); + assert_eq!( + current_attempt( + &store, + &owner, + relay, + &owner.public_key().to_hex(), + host.public_key() + ) + .unwrap() + .unwrap() + .command + .id + .to_hex(), + second_id + ); + store.journal.sent.get_mut(&first_id).unwrap().supersedes = Some(second_id.clone()); + assert!( + validate_journal(&store, &owner, relay).is_err(), + "cycle cannot erase current intent" + ); + store.journal.sent.get_mut(&first_id).unwrap().supersedes = None; + store + .journal + .sent + .get_mut(&second_id) + .unwrap() + .command + .content = "corrupt".into(); + assert!(validate_journal(&store, &owner, relay).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/host_start_recovery_tests.rs b/desktop/src-tauri/src/commands/host_start_recovery_tests.rs new file mode 100644 index 00000000000..4a09fb94bb1 --- /dev/null +++ b/desktop/src-tauri/src/commands/host_start_recovery_tests.rs @@ -0,0 +1,428 @@ +//! Durable outbox/restart tests with a controllable relay admission clock. +//! The relay predicate mirrors ingest's +/-900 seconds; no DB/service is used. +use super::*; +use buzz_core_pkg::{ + host, + host_execution::{self, Action, Command, Outcome, Receipt}, +}; +use nostr::{JsonUtil, Keys}; + +fn observed(owner: &Keys, executor: &Keys) -> (Pending, Receipt) { + let registration = host::registration(owner, executor.public_key(), 99).unwrap(); + let request = Command { + v: 1, + operation: "ab".repeat(16), + relay: "wss://relay.example".into(), + agent: Keys::generate().public_key().to_hex(), + expires_at: 400, + action: Action::Start { + runtime: "buzz-agent".into(), + revision: "cd".repeat(32), + }, + }; + let command = host_execution::command(owner, ®istration, &request, 100).unwrap(); + let result = Receipt { + v: 1, + command: command.id.to_hex(), + run: request.run().into(), + request, + observed_at: 101, + outcome: Outcome::Spawned, + }; + let receipt = host_execution::receipt(executor, ®istration, &result, 101).unwrap(); + ( + Pending { + registration, + command, + receipt: Some(receipt), + published: false, + error: None, + supersedes: None, + }, + result, + ) +} + +// Exact timestamp condition of handlers/ingest.rs, plus real signature/routing +// checks. Kept here rather than altering relay admission to accommodate retries. +fn admit(p: &Pending, owner: &Keys, now: u64) -> Result<(), String> { + let event = p.receipt.as_ref().ok_or("missing")?; + host_execution::validate_transport(event, &p.registration, owner.public_key())?; + if now.abs_diff(event.created_at.as_secs()) > 900 { + return Err("timestamp drift".into()); + } + Ok(()) +} + +#[tokio::test] +async fn late_never_accepted_receipt_survives_restart_without_new_execution() { + let dir = tempfile::tempdir().unwrap(); + let owner = Keys::generate(); + let executor = Keys::generate(); + let (pending, observation) = observed(&owner, &executor); + let original = pending.receipt.clone().unwrap(); + let command = pending.command.as_json(); + let id = pending.command.id.to_hex(); + let relay = observation.request.relay.clone(); + let mut store = Store::open_dir(dir.path(), "owner", &relay).unwrap(); + store.journal.received.insert(id.clone(), pending); + store.save().unwrap(); + retry_pending(&mut store.journal.received, true, |_| async { + Err("never reached relay".into()) + }) + .await; + store.save().unwrap(); + drop(store); + + // More than the admission window AND the command lifetime, after restart. + let now = 101 + 901; + let mut store = Store::open_dir(dir.path(), "owner", &relay).unwrap(); + assert_eq!( + admit(&store.journal.received[&id], &owner, now), + Err("timestamp drift".into()) + ); + assert!( + host_execution::decrypt_command( + &executor, + &store.journal.received[&id].registration, + &store.journal.received[&id].command, + &relay, + now + ) + .is_err(), + "expired intent cannot execute" + ); + prepare_receipt( + store.journal.received.get_mut(&id).unwrap(), + &owner, + &executor, + &relay, + &[], + now, + ) + .unwrap(); + store.save().unwrap(); + let renewed = store.journal.received[&id].receipt.clone().unwrap(); + assert_ne!(renewed.id, original.id); + assert_eq!(renewed.created_at.as_secs(), now); + assert_eq!( + renewed.content, original.content, + "same encrypted proof, no new observation" + ); + assert_eq!(renewed.tags, original.tags); + assert_eq!(store.journal.received[&id].command.as_json(), command); + drop(store); // crash after preparing, before send + + let mut store = Store::open_dir(dir.path(), "owner", &relay).unwrap(); + let pending = store.journal.received.get_mut(&id).unwrap(); + prepare_receipt(pending, &owner, &executor, &relay, &[], now + 1).unwrap(); + assert_eq!( + pending.receipt.as_ref().unwrap().as_json(), + renewed.as_json() + ); + retry_pending(&mut store.journal.received, true, |p| { + let owner = &owner; + async move { admit(&p, owner, now + 1) } + }) + .await; + assert!(store.journal.received[&id].published); + let pending = &store.journal.received[&id]; + // What the source learns from accepted history is the original exact result. + assert_eq!( + host_execution::decrypt_receipt( + &owner, + &pending.registration, + &renewed, + &pending.command, + &observation.request + ) + .unwrap(), + observation + ); + store.save().unwrap(); + drop(store); + assert!( + Store::open_dir(dir.path(), "owner", &relay) + .unwrap() + .journal + .received[&id] + .published + ); +} + +#[tokio::test] +async fn accepted_but_ack_lost_uses_history_without_renewing_proof() { + let dir = tempfile::tempdir().unwrap(); + let owner = Keys::generate(); + let executor = Keys::generate(); + let (pending, observation) = observed(&owner, &executor); + let original = pending.receipt.clone().unwrap(); + let id = pending.command.id.to_hex(); + let relay = &observation.request.relay; + let mut store = Store::open_dir(dir.path(), "owner", relay).unwrap(); + store.journal.received.insert(id.clone(), pending); + store.save().unwrap(); + store + .retry_receipts( + |p| std::future::ready(Ok(p)), + |p| { + let owner = &owner; + async move { + admit(&p, owner, 101)?; + Err("accepted, but ACK lost".into()) + } + }, + ) + .await + .unwrap(); + drop(store); + let mut store = Store::open_dir(dir.path(), "owner", relay).unwrap(); + store + .retry_receipts( + |mut p| { + let result = prepare_receipt( + &mut p, + &owner, + &executor, + relay, + std::slice::from_ref(&original), + 2000, + ) + .map(|()| p); + std::future::ready(result) + }, + |_| async { panic!("accepted history must suppress another publication") }, + ) + .await + .unwrap(); + assert!(store.journal.received[&id].published); + assert_eq!( + store.journal.received[&id] + .receipt + .as_ref() + .unwrap() + .as_json(), + original.as_json() + ); +} + +#[test] +fn history_of_an_earlier_envelope_also_resolves_ambiguous_delivery() { + let owner = Keys::generate(); + let executor = Keys::generate(); + let (mut pending, observation) = observed(&owner, &executor); + let original = pending.receipt.clone().unwrap(); + prepare_receipt( + &mut pending, + &owner, + &executor, + &observation.request.relay, + &[], + 2000, + ) + .unwrap(); + let renewed = pending.receipt.clone().unwrap(); + prepare_receipt( + &mut pending, + &owner, + &executor, + &observation.request.relay, + &[original], + 3000, + ) + .unwrap(); + assert!(pending.published); + assert_eq!(pending.receipt.unwrap(), renewed); +} + +#[test] +fn recovery_rejects_wrong_signer_scope_registration_missing_and_tampered_evidence() { + let owner = Keys::generate(); + let executor = Keys::generate(); + let (pending, observation) = observed(&owner, &executor); + let relay = &observation.request.relay; + assert!(prepare_receipt( + &mut pending.clone(), + &owner, + &Keys::generate(), + relay, + &[], + 2000 + ) + .is_err()); + assert!(prepare_receipt( + &mut pending.clone(), + &Keys::generate(), + &executor, + relay, + &[], + 2000 + ) + .is_err()); + assert!(prepare_receipt( + &mut pending.clone(), + &owner, + &executor, + "wss://elsewhere.example", + &[], + 2000 + ) + .is_err()); + let mut changed = pending.clone(); + changed.registration = host::registration(&owner, executor.public_key(), 100).unwrap(); + assert!(prepare_receipt(&mut changed, &owner, &executor, relay, &[], 2000).is_err()); + let mut changed = pending.clone(); + changed.receipt = None; + assert!(prepare_receipt(&mut changed, &owner, &executor, relay, &[], 2000).is_err()); + let mut changed = pending; + changed.receipt.as_mut().unwrap().content.push('x'); + assert!(prepare_receipt(&mut changed, &owner, &executor, relay, &[], 2000).is_err()); +} + +#[test] +fn forged_history_cannot_confirm_delivery_and_invalid_lifetime_cannot_be_renewed() { + let owner = Keys::generate(); + let executor = Keys::generate(); + let (mut pending, mut observation) = observed(&owner, &executor); + let original = pending.receipt.clone().unwrap(); + let forged = nostr::EventBuilder::new(original.kind, original.content.clone()) + .tags(original.tags.clone()) + .allow_self_tagging() + .sign_with_keys(&Keys::generate()) + .unwrap(); + prepare_receipt( + &mut pending, + &owner, + &executor, + &observation.request.relay, + &[forged], + 1002, + ) + .unwrap(); + assert!(!pending.published); + assert_eq!(pending.receipt.as_ref().unwrap().created_at.as_secs(), 1002); + observation.request.expires_at = 401; // original creation + COMMAND_TTL + 1 + let content = nostr::nips::nip44::encrypt( + owner.secret_key(), + &executor.public_key(), + serde_json::to_string(&observation.request).unwrap(), + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + pending.command = nostr::EventBuilder::new(pending.command.kind, content) + .tags(pending.command.tags.clone()) + .allow_self_tagging() + .custom_created_at(nostr::Timestamp::from(100)) + .sign_with_keys(&owner) + .unwrap(); + observation.command = pending.command.id.to_hex(); + pending.receipt = + Some(host_execution::receipt(&executor, &pending.registration, &observation, 101).unwrap()); + assert!(prepare_receipt( + &mut pending, + &owner, + &executor, + &observation.request.relay, + &[], + 1002 + ) + .is_err()); +} + +#[tokio::test] +async fn publication_owner_persists_before_send_and_recovers_late_unaccepted_after_restart() { + let dir = tempfile::tempdir().unwrap(); + let owner = Keys::generate(); + let executor = Keys::generate(); + let (pending, observation) = observed(&owner, &executor); + let relay = &observation.request.relay; + let id = pending.command.id.to_hex(); + let mut store = Store::open_dir(dir.path(), "owner", relay).unwrap(); + store.journal.received.insert(id.clone(), pending.clone()); + // An earlier revoked entry must not starve this one. + store.journal.received.insert("a-revoked".into(), pending); + store.save().unwrap(); + let disk = store.path.clone(); + let prepare = |mut p: Pending, now| { + prepare_receipt(&mut p, &owner, &executor, relay, &[], now)?; + Ok(p) + }; + store + .retry_receipts( + |p| std::future::ready(prepare(p, 101)), + |_| async { Err("never accepted: disconnected".into()) }, + ) + .await + .unwrap(); + drop(store); + let mut store = Store::open_dir(dir.path(), "owner", relay).unwrap(); + store + .journal + .received + .get_mut("a-revoked") + .unwrap() + .registration = host::registration(&owner, executor.public_key(), 100).unwrap(); + let attempts = std::sync::atomic::AtomicUsize::new(0); + store + .retry_receipts( + |p| std::future::ready(prepare(p, 1002)), + |p| { + let (disk, id, owner, attempts) = (&disk, &id, &owner, &attempts); + async move { + attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let durable: Journal = + serde_json::from_slice(&fs::read(disk).unwrap()).unwrap(); + assert_eq!( + durable.received[id].receipt, p.receipt, + "fsync before publication" + ); + admit(&p, owner, 1002) + } + }, + ) + .await + .unwrap(); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(store.journal.received[&id].published); + assert!(!store.journal.received["a-revoked"].published); + assert!(store.journal.received["a-revoked"].error.is_some()); + let p = &store.journal.received[&id]; + assert_eq!( + host_execution::decrypt_receipt( + &owner, + &p.registration, + p.receipt.as_ref().unwrap(), + &p.command, + &observation.request + ) + .unwrap(), + observation + ); +} + +#[tokio::test] +async fn failed_outbox_save_never_sends_a_renewed_envelope() { + let dir = tempfile::tempdir().unwrap(); + let owner = Keys::generate(); + let executor = Keys::generate(); + let (pending, observation) = observed(&owner, &executor); + let relay = &observation.request.relay; + let mut store = Store::open_dir(dir.path(), "owner", relay).unwrap(); + store + .journal + .received + .insert(pending.command.id.to_hex(), pending); + store.save().unwrap(); + store.path = dir.path().into(); // cannot atomically replace a directory + let result = store + .retry_receipts( + |mut p| { + let result = + prepare_receipt(&mut p, &owner, &executor, relay, &[], 1002).map(|()| p); + std::future::ready(result) + }, + |_| async { panic!("publication must not precede durable save") }, + ) + .await; + assert!(result.is_err()); +} diff --git a/desktop/src-tauri/src/commands/host_start_store.rs b/desktop/src-tauri/src/commands/host_start_store.rs new file mode 100644 index 00000000000..e966dbb1eb4 --- /dev/null +++ b/desktop/src-tauri/src/commands/host_start_store.rs @@ -0,0 +1,348 @@ +//! Encrypted transport outbox with immutable command and observation payloads. +//! Only a receipt transport timestamp/signature may be renewed. Authority remains in the +//! per-placement journal; transport retry must never create a new operation. +use nostr::Event; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, +}; +use tauri::AppHandle; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Pending { + pub registration: Event, + pub command: Event, + pub receipt: Option, + pub published: bool, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub supersedes: Option, +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Journal { + pub sent: BTreeMap, + pub received: BTreeMap, + #[serde(default)] + pub moves: BTreeMap, +} + +pub(super) struct Store { + _lock: File, + path: PathBuf, + pub journal: Journal, +} + +impl Store { + pub fn open(app: &AppHandle, owner: &str, relay: &str) -> Result { + let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("host-start-outbox"); + Self::open_dir(&dir, owner, relay) + } + + pub(super) fn open_dir(dir: &Path, owner: &str, relay: &str) -> Result { + fs::create_dir_all(dir).map_err(|_| "Start outbox unavailable")?; + let scope = hex::encode(Sha256::digest(format!("{owner}\n{relay}"))); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let lock = options + .open(dir.join(format!("{scope}.lock"))) + .map_err(|_| "Start outbox lock unavailable")?; + lock.try_lock() + .map_err(|_| "Start transport busy; retry shortly")?; + let path = dir.join(format!("{scope}.json")); + let journal: Journal = match fs::read(&path) { + Ok(bytes) if bytes.len() <= 64 * 1024 * 1024 => { + serde_json::from_slice(&bytes).map_err(|_| "Start outbox corrupt")? + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Journal::default(), + _ => return Err("Start outbox unreadable or full".into()), + }; + if journal.sent.len() + journal.received.len() + journal.moves.len() > 4096 { + return Err("Start outbox requires archival".into()); + } + Ok(Self { + _lock: lock, + path, + journal, + }) + } + + /// Prepare/authenticate one durable result, fsync its envelope, then publish. + /// No later entry can age an earlier prepared envelope while doing network + /// reads. A failed entry remains visible and never starves other placements. + pub async fn retry_receipts< + Prepared: std::future::Future> + Send, + Published: std::future::Future> + Send, + >( + &mut self, + mut prepare: impl FnMut(Pending) -> Prepared, + mut publish: impl FnMut(Pending) -> Published, + ) -> Result<(), String> { + let ids: Vec<_> = self.journal.received.keys().cloned().collect(); + for id in ids { + let Some(pending) = self.journal.received.get(&id).cloned() else { + continue; + }; + if pending.published { + continue; + } + match prepare(pending.clone()).await { + Ok(mut ready) => { + // Even a crash inside publish leaves exactly these signed + // bytes durable. Keep the original observation ciphertext. + self.journal.received.insert(id.clone(), ready.clone()); + self.save()?; + if !ready.published { + match publish(ready.clone()).await { + Ok(()) => { + ready.published = true; + ready.error = None; + } + Err(error) => ready.error = Some(error), + } + } + self.journal.received.insert(id, ready); + } + Err(error) => { + let mut failed = pending; + failed.error = Some(error); + self.journal.received.insert(id, failed); + } + } + self.save()?; + } + Ok(()) + } + + pub fn save(&self) -> Result<(), String> { + if self.journal.sent.len() + self.journal.received.len() + self.journal.moves.len() > 4096 { + return Err("Start outbox requires archival".into()); + } + let bytes = + serde_json::to_vec(&self.journal).map_err(|_| "Start outbox serialization failed")?; + crate::managed_agents::atomic_write_json_restricted(&self.path, &bytes) + .map_err(|_| "Start outbox write failed")?; + #[cfg(unix)] + File::open(self.path.parent().ok_or("invalid Start outbox path")?) + .and_then(|dir| dir.sync_all()) + .map_err(|_| "Start outbox sync failed")?; + Ok(()) + } +} + +/// Recover delivery of an already durable observation, never execution. The caller +/// saves the journal BEFORE sending any renewed envelope and still revalidates +/// current registration/owner authority at publication. Keep ciphertext byte-exact: +/// even ambiguous acceptance can only yield the same proof, not a new observation. +pub(super) fn prepare_receipt( + pending: &mut Pending, + owner: &nostr::Keys, + host: &nostr::Keys, + relay: &str, + history: &[Event], + now: u64, +) -> Result<(), String> { + use buzz_core_pkg::host_execution; + if pending.published { + return Ok(()); + } + // Authenticate the original command at its signed time, including its bounded + // lifetime. This is read-only evidence recovery, not admission of an expired + // intent to the executor. Wrong host/community/registration fail closed. + let request = host_execution::decrypt_command( + host, + &pending.registration, + &pending.command, + relay, + pending.command.created_at.as_secs(), + )?; + let event = pending + .receipt + .as_ref() + .ok_or("missing durable Start receipt")?; + host_execution::decrypt_receipt( + owner, + &pending.registration, + event, + &pending.command, + &request, + )?; + if event.created_at.as_secs() > now.saturating_add(30) { + return Err("receipt timestamp is in the future".into()); + } + // ACK loss is not non-acceptance. Match immutable ciphertext/routing, not only + // event ID: an earlier envelope may have committed before a later retry. + if history.iter().any(|accepted| { + accepted.content == event.content + && accepted.tags == event.tags + && host_execution::decrypt_receipt( + owner, + &pending.registration, + accepted, + &pending.command, + &request, + ) + .is_ok() + }) { + pending.published = true; + pending.error = None; + return Ok(()); + } + // Renew before the relay's +/-900s admission window, leaving time for I/O. + // Recent retries reuse exact bytes; observation/request/run remain unchanged. + if now.saturating_sub(event.created_at.as_secs()) >= 600 { + pending.receipt = Some( + nostr::EventBuilder::new(event.kind, event.content.clone()) + .allow_self_tagging() + .tags(event.tags.clone()) + .custom_created_at(nostr::Timestamp::from(now)) + .sign_with_keys(host) + .map_err(|_| "receipt transport signing failed")?, + ); + } + Ok(()) +} + +/// Attempt every eligible entry independently. Failed publication remains pending +/// and visible; an ACK is recorded only for that exact event. Caller fsyncs before +/// returning. Receipt preparation is persisted separately before this send loop. +pub(super) async fn retry_pending> + Send>( + entries: &mut BTreeMap, + receipts: bool, + mut attempt: impl FnMut(Pending) -> Fut, +) { + for pending in entries.values_mut() { + if (receipts && pending.published) || (!receipts && pending.receipt.is_some()) { + continue; + } + match attempt(pending.clone()).await { + Ok(()) => { + pending.published = true; + pending.error = None; + } + Err(error) => pending.error = Some(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core_pkg::{ + host, + host_execution::{self, Action, Command}, + }; + use nostr::{JsonUtil, Keys}; + + fn pending() -> Pending { + let owner = Keys::generate(); + let host = Keys::generate(); + let registration = host::registration(&owner, host.public_key(), 100).unwrap(); + let request = Command { + v: 1, + operation: "ab".repeat(16), + relay: "wss://relay.example".into(), + agent: Keys::generate().public_key().to_hex(), + expires_at: 400, + action: Action::Start { + runtime: "goose".into(), + revision: "cd".repeat(32), + }, + }; + let command = host_execution::command(&owner, ®istration, &request, 100).unwrap(); + Pending { + registration, + command, + receipt: None, + published: false, + error: None, + supersedes: None, + } + } + + #[tokio::test] + async fn revoked_entry_and_dropped_ack_do_not_starve_later_intents_and_restart_reuses_bytes() { + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open_dir(dir.path(), "owner", "relay").unwrap(); + let old = pending(); + let later = pending(); + let exact = later.command.as_json(); + store.journal.sent.insert("a-revoked".into(), old); + store.journal.sent.insert("b-current".into(), later); + store.save().unwrap(); + let attempts = std::sync::atomic::AtomicUsize::new(0); + retry_pending(&mut store.journal.sent, false, |_| async { + let count = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(if count == 0 { "revoked" } else { "ACK dropped" }.into()) + }) + .await; + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); + store.save().unwrap(); + assert!( + Store::open_dir(dir.path(), "owner", "relay").is_err(), + "cross-process lock" + ); + drop(store); + let mut store = Store::open_dir(dir.path(), "owner", "relay").unwrap(); + assert_eq!( + store.journal.sent["b-current"].error.as_deref(), + Some("ACK dropped") + ); + assert_eq!(store.journal.sent["b-current"].command.as_json(), exact); + retry_pending(&mut store.journal.sent, false, |p| { + let exact = &exact; + async move { + if p.command.as_json() == *exact { + Ok(()) + } else { + Err("revoked".into()) + } + } + }) + .await; + assert!(store.journal.sent["b-current"].published); + assert!(store.journal.sent["b-current"].error.is_none()); + assert_eq!( + store.journal.sent["a-revoked"].error.as_deref(), + Some("revoked") + ); + store.save().unwrap(); + drop(store); + let store = Store::open_dir(dir.path(), "owner", "relay").unwrap(); + assert!(store.journal.sent["b-current"].published); + assert_eq!(store.journal.sent["b-current"].command.as_json(), exact); + assert!(Store::open_dir(dir.path(), "owner", "other-relay") + .unwrap() + .journal + .sent + .is_empty()); + } + + #[test] + fn malformed_outbox_is_never_replaced_by_an_empty_journal() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open_dir(dir.path(), "owner", "relay").unwrap(); + let path = store.path.clone(); + drop(store); + for corrupt in ["{", "{}", r#"{"sent":{},"received":{},"unexpected":true}"#] { + fs::write(&path, corrupt).unwrap(); + assert!(Store::open_dir(dir.path(), "owner", "relay").is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), corrupt); + } + } +} + +#[cfg(test)] +#[path = "host_start_recovery_tests.rs"] +mod recovery_tests; diff --git a/desktop/src-tauri/src/commands/hosts.rs b/desktop/src-tauri/src/commands/hosts.rs new file mode 100644 index 00000000000..7c1974e2808 --- /dev/null +++ b/desktop/src-tauri/src/commands/hosts.rs @@ -0,0 +1,485 @@ +//! Desktop self-registration. All keys remain in Rust/keychain, never IPC. +use buzz_core_pkg::host::{self, Report, Runtime}; +use nostr::{Event, JsonUtil, Keys, Timestamp}; +use serde::Serialize; +use tauri::State; + +use crate::app_state::AppState; +use crate::managed_agents::AuthStatus; + +#[derive(Serialize)] +pub struct LocalHost { + pub host: String, + pub report: Report, +} + +pub(super) fn owner_keys(state: &AppState, expected_owner: &str) -> Result { + let keys = state.signing_keys()?; + if keys.public_key().to_hex() != expected_owner { + return Err("Identity changed during host registration".into()); + } + Ok(keys) +} + +pub(super) fn host_keys(owner: &Keys) -> Result { + let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + let secret = store.host_key(&format!("host:v1:{}", owner.public_key().to_hex()))?; + Keys::parse(&secret).map_err(|e| format!("Invalid stored host key: {e}")) +} + +fn parse(value: serde_json::Value) -> Result { + Event::from_json(value.to_string()).map_err(|e| e.to_string()) +} + +fn value(event: Event) -> Result { + serde_json::to_value(event).map_err(|e| e.to_string()) +} + +fn catalog_string(value: T) -> Result { + serde_json::to_value(value) + .map_err(|e| e.to_string())? + .as_str() + .map(str::to_owned) + .ok_or_else(|| "Invalid runtime catalog status".into()) +} + +// AuthStatus is a tagged union, not a string enum. Never serialize its +// diagnostic: CLI errors may include credentials, paths, or configuration. +fn auth_status(value: &AuthStatus) -> &'static str { + match value { + AuthStatus::LoggedIn => "logged_in", + AuthStatus::LoggedOut => "logged_out", + AuthStatus::ConfigInvalid { .. } => "config_invalid", + AuthStatus::NotApplicable => "not_applicable", + AuthStatus::Unknown => "unknown", + } +} + +/// Real OS metadata and the existing runtime catalog, with a strict allowlist. +#[tauri::command] +pub async fn get_local_host( + app: tauri::AppHandle, + state: State<'_, AppState>, + expected_owner: String, +) -> Result { + let owner = owner_keys(&state, &expected_owner)?; + let relay = buzz_core_pkg::relay::normalize_relay_url( + &crate::relay::relay_ws_url_with_override(&state), + ) + .map_err(|_| "invalid host community")?; + let catalog = super::discover_acp_providers(app.clone(), Some(false)).await?; + let result = tokio::task::spawn_blocking(move || { + let host = host_keys(&owner)?; + let mut runtimes = catalog + .into_iter() + .map(|r| { + Ok(Runtime { + id: r.id, + label: r.label, + availability: catalog_string(r.availability)?, + auth_status: auth_status(&r.auth_status).into(), + }) + }) + .collect::, String>>()?; + runtimes.sort_by(|a, b| a.id.cmp(&b.id)); + let provisioned: Vec<_> = crate::managed_agents::load_managed_agents(&app)? + .iter() + .filter(|r| { + buzz_core_pkg::relay::normalize_relay_url(&r.relay_url) + .ok() + .as_deref() + == Some(relay.as_str()) + && crate::managed_agents::execution_agent_owner(r, &owner.public_key().to_hex()) + .is_ok() + }) + .filter_map(|r| { + crate::managed_agents::local_execution_config(&app, r) + .ok() + .map(|c| host::ProvisionedAgent { + agent: r.pubkey.clone(), + runtime: c.runtime, + revision: c.revision, + }) + }) + .filter(|c| { + runtimes.iter().any(|r| { + r.id == c.runtime + && r.availability == "available" + && matches!(r.auth_status.as_str(), "logged_in" | "not_applicable") + }) + }) + .collect(); + let report = Report { + v: 3, + name: gethostname::gethostname().to_string_lossy().into_owned(), + os: std::env::consts::OS.into(), + arch: std::env::consts::ARCH.into(), + launcher_version: env!("CARGO_PKG_VERSION").into(), + accepts_start: !provisioned.is_empty() + && super::host_start::receiver_healthy(&app, &owner.public_key().to_hex(), &relay), + runtimes, + provisioned, + }; + report.validate()?; + Ok(LocalHost { + host: host.public_key().to_hex(), + report, + }) + }) + .await + .map_err(|e| e.to_string())?; + owner_keys(&state, &expected_owner)?; + result +} + +/// Owner-signed binding for a persisted host key; caller first queries the relay. +#[tauri::command] +pub async fn create_host_registration( + state: State<'_, AppState>, + expected_owner: String, +) -> Result { + let owner = owner_keys(&state, &expected_owner)?; + let result = tokio::task::spawn_blocking(move || { + let host = host_keys(&owner)?; + value(host::registration( + &owner, + host.public_key(), + Timestamp::now().as_secs(), + )?) + }) + .await + .map_err(|e| e.to_string())?; + owner_keys(&state, &expected_owner)?; + result +} + +/// Produce a report from native discovery, not caller-provided machine metadata. +#[tauri::command] +pub async fn create_host_report( + app: tauri::AppHandle, + state: State<'_, AppState>, + expected_owner: String, + registration: serde_json::Value, +) -> Result { + let local = get_local_host(app, state.clone(), expected_owner.clone()).await?; + let owner = owner_keys(&state, &expected_owner)?; + let result = tokio::task::spawn_blocking(move || { + let reg = parse(registration)?; + if host::validate(®)?.owner != owner.public_key() { + return Err("Foreign host registration".into()); + } + value(host::profile( + &host_keys(&owner)?, + ®, + &local.report, + Timestamp::now().as_secs(), + )?) + }) + .await + .map_err(|e| e.to_string())?; + owner_keys(&state, &expected_owner)?; + result +} + +/// Verify a registration before displaying it or using it to suppress a write. +#[tauri::command] +pub async fn inspect_host_registration( + state: State<'_, AppState>, + expected_owner: String, + registration: serde_json::Value, +) -> Result { + let owner = owner_keys(&state, &expected_owner)?; + let reg = parse(registration)?; + let env = host::validate(®)?; + if env.label != "registration" || env.owner != owner.public_key() { + return Err("Foreign host registration".into()); + } + let text = nostr::nips::nip44::decrypt(owner.secret_key(), &owner.public_key(), ®.content) + .map_err(|e| e.to_string())?; + let body: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; + if body != serde_json::json!({"v": 1}) { + return Err("Unknown host registration version".into()); + } + Ok(env.host.to_hex()) +} + +/// Verify signatures and owner/host binding before decrypting an incoming report. +#[tauri::command] +pub async fn decode_host_report( + state: State<'_, AppState>, + expected_owner: String, + registration: serde_json::Value, + report: serde_json::Value, +) -> Result { + let owner = owner_keys(&state, &expected_owner)?; + host::decrypt_report(&owner, &parse(registration)?, &parse(report)?) +} + +/// Sign a liveness pulse without exporting keys or tying it to human idle state. +#[tauri::command] +pub async fn create_host_presence( + state: State<'_, AppState>, + expected_owner: String, + registration: serde_json::Value, + run: String, + seq: u64, + status: String, +) -> Result { + let owner = owner_keys(&state, &expected_owner)?; + let result = tokio::task::spawn_blocking(move || { + let host = host_keys(&owner)?; + let reg = parse(registration)?; + let binding = host::validate(®)?; + if binding.label != "registration" + || binding.owner != owner.public_key() + || binding.host != host.public_key() + { + return Err("Foreign host registration".into()); + } + value(buzz_core_pkg::run_presence::pulse( + &host, + &run, + seq, + &status, + None, + Some(®.id.to_hex()), + Timestamp::now().as_secs(), + )?) + }) + .await + .map_err(|e| e.to_string())?; + owner_keys(&state, &expected_owner)?; + result +} + +/// Public location for local launches. Never export the private OS name by default. +pub(crate) fn local_launch_location( + owner_hex: &str, +) -> Result { + let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + let secret = store.host_key(&format!("host:v1:{owner_hex}"))?; + let host = Keys::parse(&secret) + .map_err(|e| e.to_string())? + .public_key() + .to_hex(); + Ok(buzz_core_pkg::run_presence::Location { + label: format!("Desktop {}", &host[..8]), + host, + }) +} + +/// Detailed presence snapshot. Missing protocol support and query errors stay unknown. +#[tauri::command] +pub async fn get_presence_runs( + state: State<'_, AppState>, + expected_owner: String, + relay_url: String, + pubkeys: Vec, +) -> Result>, String> +{ + let owner = owner_keys(&state, &expected_owner)?; + if pubkeys.len() > 256 { + return Err("Too many presence subjects".into()); + } + if pubkeys.is_empty() { + return Ok(Default::default()); + } + let relay_self = super::identity_archive::fetch_relay_self_at(&state, &relay_url) + .await? + .ok_or("Relay did not advertise a presence snapshot signer")?; + let events = crate::relay::query_relay_at_with_keys( + &state, + &crate::relay::relay_http_base_url(&relay_url), + &[serde_json::json!({ "kinds": [40902], "authors": pubkeys })], + &owner, + None, + ) + .await?; + owner_keys(&state, &expected_owner)?; + decode_presence_snapshot(events, &pubkeys, &relay_self, Timestamp::now().as_secs()) +} + +fn decode_presence_snapshot( + events: Vec, + pubkeys: &[String], + relay_self: &str, + now: u64, +) -> Result>, String> +{ + let mut result = std::collections::HashMap::new(); + for event in events { + buzz_core_pkg::verify_event(&event).map_err(|e| e.to_string())?; + if event.kind.as_u16() != 20001 || event.pubkey.to_hex() != relay_self { + return Err("Invalid presence snapshot authority".into()); + } + let subject = event + .tags + .iter() + .find_map(|t| { + let t = t.as_slice(); + (t.len() == 2 && t[0] == "p").then(|| t[1].clone()) + }) + .ok_or("Missing presence subject")?; + if !pubkeys.contains(&subject) { + return Err("Unexpected presence subject".into()); + } + let payload = event + .tags + .iter() + .find_map(|t| { + let t = t.as_slice(); + (t.len() == 3 && t[0] == "presence_runs" && t[1] == "1").then(|| t[2].clone()) + }) + .ok_or("Relay does not support live host locations")?; + let runs: Vec = + serde_json::from_str(&payload).map_err(|e| e.to_string())?; + validate_snapshot_runs(&runs, now)?; + if result.insert(subject, runs).is_some() { + return Err("Duplicate presence snapshot subject".into()); + } + } + if result.len() + != pubkeys + .iter() + .collect::>() + .len() + { + return Err("Incomplete presence snapshot".into()); + } + Ok(result) +} + +fn validate_snapshot_runs( + runs: &[buzz_core_pkg::run_presence::RunPresence], + now: u64, +) -> Result<(), String> { + let mut ids = std::collections::HashSet::new(); + if runs.len() > 32 { + return Err("Invalid presence snapshot".into()); + } + for run in runs { + if run.run.len() != 32 + || !run + .run + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || !ids.insert(&run.run) + || run.seq > 9_007_199_254_740_991 + || !matches!(run.status.as_str(), "online" | "away") + || run.expires_at > now.saturating_add(buzz_core_pkg::run_presence::LEASE_SECONDS) + { + return Err("Invalid presence run".into()); + } + if let Some(location) = &run.location { + location.validate()?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::AcpAvailabilityStatus; + + #[test] + fn snapshots_require_the_selected_relays_signature_and_complete_subjects() { + let relay = Keys::generate(); + let impostor = Keys::generate(); + let subject = Keys::generate().public_key().to_hex(); + let snapshot = |signer: &Keys| { + nostr::EventBuilder::new(nostr::Kind::Custom(20001), "offline") + .tags([ + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["presence_runs", "1", "[]"]).unwrap(), + ]) + .sign_with_keys(signer) + .unwrap() + }; + let subjects = vec![subject.clone()]; + let signer = relay.public_key().to_hex(); + let good = snapshot(&relay); + assert!(decode_presence_snapshot(vec![good.clone()], &subjects, &signer, 100).is_ok()); + assert!( + decode_presence_snapshot(vec![snapshot(&impostor)], &subjects, &signer, 100).is_err() + ); + assert!(decode_presence_snapshot(vec![], &subjects, &signer, 100).is_err()); + assert!( + decode_presence_snapshot(vec![good.clone(), good], &subjects, &signer, 100).is_err() + ); + } + + #[test] + fn every_auth_status_projects_without_diagnostics() { + let sensitive = "/private/credentials TOKEN=do-not-publish"; + for (status, expected) in [ + (AuthStatus::LoggedIn, "logged_in"), + (AuthStatus::LoggedOut, "logged_out"), + ( + AuthStatus::ConfigInvalid { + diagnostic: sensitive.into(), + }, + "config_invalid", + ), + (AuthStatus::NotApplicable, "not_applicable"), + (AuthStatus::Unknown, "unknown"), + ] { + // Pin the projection to the real native tagged-union contract. + assert_eq!(serde_json::to_value(&status).unwrap()["status"], expected); + let runtime = Runtime { + id: "test-runtime".into(), + label: "Test runtime".into(), + availability: catalog_string(AcpAvailabilityStatus::Available).unwrap(), + auth_status: auth_status(&status).into(), + }; + let serialized = serde_json::to_value(runtime).unwrap(); + assert_eq!(serialized["auth_status"], expected); + assert_eq!(serialized.as_object().unwrap().len(), 4); + assert!(!serialized.to_string().contains(sensitive)); + assert!(!serialized.to_string().contains("diagnostic")); + } + } + + #[test] + fn every_availability_is_a_string_enum() { + for (status, expected) in [ + (AcpAvailabilityStatus::Available, "available"), + (AcpAvailabilityStatus::AdapterMissing, "adapter_missing"), + (AcpAvailabilityStatus::AdapterOutdated, "adapter_outdated"), + (AcpAvailabilityStatus::CliMissing, "cli_missing"), + (AcpAvailabilityStatus::NotInstalled, "not_installed"), + ] { + assert_eq!(catalog_string(status).unwrap(), expected); + } + } +} + +/// Read one exact owner-private inventory page on the selected relay. The HTTP +/// bridge preserves the timestamp + event-ID cursor (ordinary WS REQ does not). +#[tauri::command] +pub async fn get_host_history_page( + state: State<'_, AppState>, + expected_owner: String, + relay_url: String, + filter: serde_json::Value, +) -> Result, String> { + let owner = owner_keys(&state, &expected_owner)?; + if filter.get("kinds") != Some(&serde_json::json!([50000])) + || filter.get("#p") != Some(&serde_json::json!([expected_owner])) + || filter.get("#L") != Some(&serde_json::json!(["buzz.host.v1"])) + || filter.get("limit") != Some(&serde_json::json!(1000)) + { + return Err("Invalid host history scope".into()); + } + let events = crate::relay::query_private_host_at_with_keys( + &state, + &crate::relay::relay_http_base_url(&relay_url), + &[filter], + &owner, + None, + ) + .await + .map_err(|_| "Host history query failed".to_string())?; + owner_keys(&state, &expected_owner)?; + Ok(events) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7cb2d8e3b83..1ebc342c472 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -21,6 +21,12 @@ mod dms; mod engrams; mod export_util; mod global_agent_config; +mod host_execution; +mod host_move; +mod host_start; +pub use host_move::*; +mod host_start_store; +mod hosts; mod identity; mod identity_archive; mod join_policy; @@ -90,6 +96,9 @@ pub use clipboard::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; +pub use host_execution::*; +pub use host_start::*; +pub use hosts::*; pub use identity::*; pub use identity_archive::*; pub use join_policy::*; diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac5709..3dfa7fe696a 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -353,8 +353,7 @@ pub async fn get_presence( "authors": pubkeys, })], ) - .await - .unwrap_or_default(); + .await?; let mut latest: HashMap = HashMap::new(); for ev in &events { diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs index db58ddafa05..e387b84b653 100644 --- a/desktop/src-tauri/src/egress_guard.rs +++ b/desktop/src-tauri/src/egress_guard.rs @@ -14,6 +14,7 @@ //! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` | //! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` | //! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` | +//! | 9 | owner-operated host Start command/receipt publisher | `commands/host_start.rs` | //! //! The inventory-completeness test in `egress_guard_tests.rs` asserts that //! every `/events` URL-construction site in the tree calls this guard, so a diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 0e718079a30..8efaadff3d8 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -268,6 +268,7 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ ("src/commands/team_snapshot.rs", 1, 1), // boundary 6 ("src/commands/personas/snapshot/import.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) + ("src/commands/host_start.rs", 1, 1), // boundary 9 (commands and receipts) // Test-only fixtures — no production egress, no guard: ("src/relay_admission.rs", 1, 0), ("src/archive/mod_tests.rs", 1, 0), @@ -452,6 +453,7 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() { "src/huddle/pipeline.rs", "src/commands/team_snapshot.rs", "src/commands/team_snapshot/tests.rs", + "src/commands/host_start.rs", // boundary-9 injection fixture only "src/commands/personas/snapshot/import.rs", "src/native_websocket.rs", ]; diff --git a/desktop/src-tauri/src/host_move_tracer.rs b/desktop/src-tauri/src/host_move_tracer.rs new file mode 100644 index 00000000000..017b1bd8b77 --- /dev/null +++ b/desktop/src-tauri/src/host_move_tracer.rs @@ -0,0 +1,498 @@ +//! Opt-in tracer extension; no manufactured Stopped observations. On a baseline +//! executor the expected result is blocked Move, NOT a successful migration. +use super::{keys, save}; +use crate::{app_state::AppState, commands, managed_agents, relay}; +use nostr::{Event, JsonUtil, ToBech32}; +use serde_json::{json, Value}; +use std::{path::Path, time::Duration}; +use tauri::Manager; + +async fn pump(app: &tauri::AppHandle, owner: &str, relay: &str) -> Result { + serde_json::to_value( + commands::pump_host_start( + app.clone(), + app.state::(), + owner.into(), + relay.into(), + ) + .await?, + ) + .map_err(|_| "snapshot".into()) +} +fn text(v: &Value) -> Result { + v.as_str() + .map(str::to_owned) + .ok_or("fixture field absent".into()) +} +fn read(root: &Path, name: &str) -> Result { + serde_json::from_slice(&std::fs::read(root.join(name)).map_err(|_| "fixture file absent")?) + .map_err(|_| "fixture JSON invalid".into()) +} + +pub(super) async fn trace( + app: &tauri::AppHandle, + role: &str, + root: &Path, + relay_url: &str, +) -> Result<(), String> { + let state = app.state::(); + let owner = state.signing_keys()?; + let owner_hex = owner.public_key().to_hex(); + let mut socket = + buzz_ws_client_pkg::NostrWsConnection::connect_authenticated(relay_url, &owner, None) + .await + .map_err(|_| "fixture owner socket")?; + // Restart reuses the exact registration, instead of changing its ID. + let file = format!("{role}.json"); + let registration = match read(root, &file) { + Ok(value) => value["registration"].clone(), + Err(_) => commands::create_host_registration(state.clone(), owner_hex.clone()).await?, + }; + let reg = Event::from_json(registration.to_string()).map_err(|_| "registration")?; + relay::submit_signed_event_with_keys(®, &state, &owner, None).await?; + let mut records = Vec::new(); + for name in if role == "move-source" { + vec!["agent", "peer"] + } else { + vec!["agent"] + } { + let agent = keys(root, name)?; + let record = serde_json::from_value::(json!({ + "pubkey":agent.public_key().to_hex(), "name":format!("Move tracer {name}"), "private_key_nsec":agent.secret_key().to_bech32().map_err(|_| "fixture encoding")?, + "auth_tag":buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").map_err(|_| "fixture attestation")?, + "relay_url":relay_url, "acp_command":"buzz-acp", "agent_command":"buzz-agent", "agent_command_override":"buzz-agent", "agent_args":[], "mcp_command":"buzz-dev-mcp", + "turn_timeout_seconds":320, "system_prompt":"Synthetic local Move tracer. Do not perform work.", "model":"fixture", "provider":"openai", + "env_vars":{"OPENAI_COMPAT_API_KEY":"synthetic-not-a-credential", "OPENAI_COMPAT_BASE_URL":"http://127.0.0.1:18992/v1"}, + "start_on_app_launch":false, "auto_restart_on_config_change":false, "created_at":"2026-08-31T00:00:00Z", "updated_at":"2026-08-31T00:00:00Z", + "last_started_at":null, "last_stopped_at":null, "last_exit_code":null, "last_error":null + })).map_err(|_| "fixture record")?; + records.push(record); + } + // Never overwrite a running/restarted fixture's records or erase journals. + if managed_agents::load_managed_agents(app)?.is_empty() { + managed_agents::save_managed_agents(app, &records)?; + } + let agent = keys(root, "agent")?.public_key().to_hex(); + let config = commands::inspect_local_execution_config( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + agent.clone(), + ) + .await?; + pump(app, &owner_hex, relay_url).await?; + let report = commands::create_host_report( + app.clone(), + state.clone(), + owner_hex.clone(), + registration.clone(), + ) + .await?; + let event = Event::from_json(report.to_string()).map_err(|_| "report")?; + let ack = socket + .send_event(event) + .await + .map_err(|_| "fixture host publication failed")?; + if !ack.accepted { + return Err(format!( + "fixture host publication rejected: {}", + ack.message + )); + } + let host_run = uuid::Uuid::new_v4().simple().to_string(); + pulse(app, &owner_hex, ®istration, &host_run, 0, &mut socket).await?; + save( + root, + &file, + &json!({"registration":registration,"config":config,"agent":agent}), + )?; + if role == "move-destination" { + for tick in 0..240 { + if tick % 30 == 0 { + pulse( + app, + &owner_hex, + ®istration, + &host_run, + tick + 1, + &mut socket, + ) + .await?; + } + save( + root, + "move-destination-progress.json", + &pump(app, &owner_hex, relay_url).await?, + )?; + if root.join("move-finish").exists() { + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + if root.join("move-finish").exists() { + let old = read(root, "selected-source.json")?; + if managed_agents::stop_managed_agent_runtime( + agent.clone(), + relay_url.into(), + Some(text(&old["run"])?), + app.clone(), + ) + .is_ok() + { + return Err("stale source Stop targeted destination successor".into()); + } + save( + root, + "successor-stale-stop-rejected.json", + &json!({"rejected":true}), + )?; + } + return cleanup(app); + } + let peer = keys(root, "peer")?.public_key().to_hex(); + let peer_config = commands::inspect_local_execution_config( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + peer.clone(), + ) + .await?; + let run = commands::queue_host_start( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + registration.clone(), + agent.clone(), + text(&config["runtime"])?, + text(&config["revision"])?, + None, + ) + .await?; + let peer_run = commands::queue_host_start( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + registration.clone(), + peer.clone(), + text(&peer_config["runtime"])?, + text(&peer_config["revision"])?, + None, + ) + .await?; + let mut selected_pid = None; + let mut peer_pid = None; + for _ in 0..90 { + let snapshot = pump(app, &owner_hex, relay_url).await?; + save(root, "move-source-progress.json", &snapshot)?; + let runs = commands::get_presence_runs( + state.clone(), + owner_hex.clone(), + relay_url.into(), + vec![agent.clone(), peer.clone()], + ) + .await?; + if runs + .get(&agent) + .is_some_and(|r| r.iter().any(|r| r.run == run)) + && runs + .get(&peer) + .is_some_and(|r| r.iter().any(|r| r.run == peer_run)) + { + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|_| "runtime lock")?; + selected_pid = runtimes + .get(&managed_agents::ManagedAgentRuntimeKey::new( + &agent, relay_url, + )?) + .map(|r| r.child.id()); + peer_pid = runtimes + .get(&managed_agents::ManagedAgentRuntimeKey::new( + &peer, relay_url, + )?) + .map(|r| r.child.id()); + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + if selected_pid.is_none() || peer_pid.is_none() { + return Err("source and peer not observed live".into()); + } + // Exercise the ordinary Desktop Stop boundary with a stale clicked nonce. + // It must neither stop this current generation nor create a placement fence. + if managed_agents::stop_managed_agent_runtime( + agent.clone(), + relay_url.into(), + Some("00".repeat(16)), + app.clone(), + ) + .is_ok() + { + return Err("ordinary Stop accepted a stale clicked generation".into()); + } + save(root, "selected-source.json", &json!({"run":run}))?; + let destination = read(root, "move-destination.json")?; + let movement = commands::queue_host_move( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + registration.clone(), + run.clone(), + destination["registration"].clone(), + agent.clone(), + text(&destination["config"]["runtime"])?, + text(&destination["config"]["revision"])?, + ) + .await?; + // Double-click retry must return precisely the same persisted Move. + let retry = commands::queue_host_move( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + registration, + run.clone(), + destination["registration"].clone(), + agent.clone(), + text(&destination["config"]["runtime"])?, + text(&destination["config"]["revision"])?, + ) + .await?; + if retry != movement { + return Err("Move duplicated on retry".into()); + } + for _ in 0..120 { + let snapshot = pump(app, &owner_hex, relay_url).await?; + save(root, "move-source-progress.json", &snapshot)?; + let movement = snapshot["moves"] + .as_array() + .and_then(|m| m.iter().find(|m| m["operation"] == movement)); + if let Some(movement) = movement { + let status = movement["status"].as_str().unwrap_or(""); + if status == "stop_unconfirmed" || status == "destination_spawned" { + let peer_alive = { + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|_| "runtime lock")?; + let key = managed_agents::ManagedAgentRuntimeKey::new(&peer, relay_url)?; + runtimes.get_mut(&key).is_some_and(|r| { + Some(r.child.id()) == peer_pid && matches!(r.child.try_wait(), Ok(None)) + }) + }; + if !peer_alive { + return Err("unrelated peer did not survive Move".into()); + } + let runs = commands::get_presence_runs( + state.clone(), + owner_hex.clone(), + relay_url.into(), + vec![agent.clone(), peer.clone()], + ) + .await?; + let new_run = text(&movement["destination_run"])?; + let new_host = text(&movement["destination_host"])?; + let matched = runs.get(&agent).is_some_and(|r| { + r.iter().any(|r| { + r.run == new_run && r.location.as_ref().is_some_and(|l| l.host == new_host) + }) + }); + if status == "destination_spawned" && !matched { + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + save( + root, + if status == "stop_unconfirmed" { + "move-blocked.json" + } else { + "move-success.json" + }, + &json!({ + "snapshot":snapshot,"presence":runs,"source_run":run,"selected_pid":selected_pid,"peer_run":peer_run,"peer_pid":peer_pid,"peer_alive":peer_alive, + "same_identity":agent,"matched_new_run_host":matched,"physical_hosts":1,"executors":2,"provider":"synthetic fixture; no inference turn", + "result":status,"certification":"Only native signed receipt outcomes; no manufactured Stopped" + }), + )?; + if status == "stop_unconfirmed" { + cleanup(app)?; + return Err("Move correctly blocked: source has no certified Stopped receipt (see move-blocked.json)".into()); + } + if state + .managed_agent_processes + .lock() + .map_err(|_| "runtime lock")? + .contains_key(&managed_agents::ManagedAgentRuntimeKey::new( + &agent, relay_url, + )?) + { + return Err("source runtime remains tracked after confirmed Move".into()); + } + // Ordinary existing Stop must consume the real peer's supported + // proof and persist Stopped through the same execution authority. + let peer_stop = managed_agents::stop_managed_agent_runtime( + peer.clone(), + relay_url.into(), + Some(peer_run.clone()), + app.clone(), + )?; + save( + root, + "ordinary-stop-success.json", + &serde_json::to_value(peer_stop).map_err(|_| "Stop status")?, + )?; + exercise_ordinary_restart(app, root, &peer, relay_url, &peer_run).await?; + save(root, "move-finish", &json!({"done":true}))?; + cleanup(app)?; + return Ok(()); + } + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + cleanup(app)?; + Err("Move remains unconfirmed; inspect persisted progress, never force Stopped".into()) +} + +// Exercise the existing pair Start/Restart entry points, not a fixture-only +// ledger shortcut. A stale Stop retry must not label a live successor stopped. +async fn exercise_ordinary_restart( + app: &tauri::AppHandle, + root: &Path, + peer: &str, + relay_url: &str, + stopped_run: &str, +) -> Result<(), String> { + let started = + managed_agents::start_managed_agent_runtime(peer.into(), relay_url.into(), app.clone())?; + let started_run = started + .run_id + .clone() + .ok_or("ordinary Start missing generation")?; + if started_run == stopped_run || started.pid.is_none() { + return Err("ordinary Start did not create a fresh runtime".into()); + } + if managed_agents::stop_managed_agent_runtime( + peer.into(), + relay_url.into(), + Some(stopped_run.into()), + app.clone(), + ) + .is_ok() + { + return Err("old successful Stop retry misreported successor as stopped".into()); + } + save( + root, + "ordinary-start-success.json", + &serde_json::to_value(&started).map_err(|_| "Start status")?, + )?; + wait_live_run(app, peer, relay_url, &started_run).await?; + let restarted = managed_agents::restart_managed_agent_runtime( + peer.into(), + relay_url.into(), + Some(started_run.clone()), + app.clone(), + )?; + let restarted_run = restarted + .run_id + .clone() + .ok_or("ordinary Restart missing generation")?; + if restarted_run == started_run || restarted.pid.is_none() { + return Err("ordinary Restart did not create a fresh runtime".into()); + } + wait_live_run(app, peer, relay_url, &restarted_run).await?; + let stopped = managed_agents::stop_managed_agent_runtime( + peer.into(), + relay_url.into(), + Some(restarted_run), + app.clone(), + )?; + save( + root, + "ordinary-restart-success.json", + &json!({ + "start_after_exact_stop": started, "restart": restarted, + "stale_successful_stop_retry_rejected": true, "final_stop": stopped, + }), + ) +} + +// Spawn is explicitly not Ready. Exercise the happy path only after observing +// this exact new runtime on the relay; an immediate startup Stop may fail closed. +async fn wait_live_run( + app: &tauri::AppHandle, + agent: &str, + relay_url: &str, + run: &str, +) -> Result<(), String> { + let state = app.state::(); + let owner = state.signing_keys()?.public_key().to_hex(); + for _ in 0..60 { + let runs = commands::get_presence_runs( + state.clone(), + owner.clone(), + relay_url.into(), + vec![agent.into()], + ) + .await?; + let now = nostr::Timestamp::now().as_secs(); + if runs.get(agent).is_some_and(|runs| { + runs.iter() + .any(|r| r.run == run && r.status != "offline" && r.expires_at > now) + }) { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + Err("ordinary restart generation not observed live".into()) +} + +async fn pulse( + app: &tauri::AppHandle, + owner: &str, + registration: &Value, + run: &str, + seq: u64, + socket: &mut buzz_ws_client_pkg::NostrWsConnection, +) -> Result<(), String> { + let state = app.state::(); + let event = commands::create_host_presence( + state.clone(), + owner.into(), + registration.clone(), + run.into(), + seq, + "online".into(), + ) + .await?; + let event = Event::from_json(event.to_string()).map_err(|_| "host pulse")?; + let ack = socket + .send_event(event) + .await + .map_err(|_| "fixture host pulse failed")?; + if !ack.accepted { + return Err(format!("fixture host pulse rejected: {}", ack.message)); + } + Ok(()) +} + +fn cleanup(app: &tauri::AppHandle) -> Result<(), String> { + let state = app.state::(); + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|_| "runtime lock")?; + for runtime in runtimes.values_mut() { + let _ = managed_agents::terminate_exact_owned_group(&mut runtime.child); + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } + println!("Tracer cleanup reaped tracked fixture roots only; cleanup is NOT certified Stop"); + Ok(()) +} diff --git a/desktop/src-tauri/src/host_start_tracer.rs b/desktop/src-tauri/src/host_start_tracer.rs new file mode 100644 index 00000000000..df63d9d93d9 --- /dev/null +++ b/desktop/src-tauri/src/host_start_tracer.rs @@ -0,0 +1,284 @@ +//! Opt-in local two-process native tracer. Synthetic fixture keys only. Does not +//! expose a production IPC bypass or start normal Desktop restore/sweep services. +use crate::{ + app_state::{build_app_state, AppState}, + commands, managed_agents, relay, +}; +use nostr::{Event, JsonUtil, Keys, ToBech32}; +use serde_json::{json, Value}; +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; +use tauri::Manager; +#[cfg(all(target_os = "macos", feature = "system-keyring"))] +mod keychain; +#[path = "host_move_tracer.rs"] +mod move_trace; + +/// Run `init|source|destination FIXTURE_DIR ws://127.0.0.1:PORT` in an isolated +/// debug build. Init writes fresh synthetic keys with restricted permissions. +pub fn run() -> Result<(), String> { + if !cfg!(debug_assertions) { + return Err("tracer requires a debug build".into()); + } + let args: Vec<_> = std::env::args().collect(); + if args.len() != 4 { + return Err( + "usage: host-start-tracer init|source|destination FIXTURE_DIR ws://127.0.0.1:PORT" + .into(), + ); + } + let role = args[1].clone(); + let root = PathBuf::from(&args[2]); + if !root.is_absolute() { + return Err("fixture path must be absolute".into()); + } + let url = url::Url::parse(&args[3]).map_err(|_| "invalid relay")?; + if url.scheme() != "ws" || url.host_str() != Some("127.0.0.1") { + return Err("tracer requires an isolated loopback relay".into()); + } + if role == "init" || role == "move-init" { + std::fs::create_dir(&root).map_err(|_| "fixture directory must be new")?; + for name in if role == "move-init" { + vec!["owner", "agent", "peer"] + } else { + vec!["owner", "agent"] + } { + managed_agents::atomic_write_json_restricted( + &root.join(format!("{name}.key")), + Keys::generate().secret_key().to_secret_hex().as_bytes(), + ) + .map_err(|_| "fixture key write failed")?; + } + println!("PASS synthetic fixture initialized (no keys printed)"); + return Ok(()); + } + if !matches!( + role.as_str(), + "source" | "destination" | "move-source" | "move-destination" + ) { + return Err("unknown role".into()); + } + // Unique per fixture and per executor: do not touch the real Desktop keyring. + use sha2::{Digest, Sha256}; + let scope = hex::encode(Sha256::digest(root.to_string_lossy().as_bytes())); + std::env::set_var( + "BUZZ_DEV_KEYRING_SERVICE", + format!("buzz-desktop-dev.start-tracer.{scope}.{role}"), + ); + let home = root.join(format!("{role}-home")); + std::fs::create_dir_all(&home).map_err(|_| "fixture home")?; + std::env::set_var("HOME", &home); + std::env::set_var("XDG_CONFIG_HOME", home.join(".config")); + if dirs::home_dir().as_ref() != Some(&home) { + return Err("cannot isolate fixture home".into()); + } + #[cfg(all(target_os = "macos", feature = "system-keyring"))] + keychain::install(&home)?; + managed_agents::init_nest_dir(true); + std::env::remove_var("BUZZ_PRIVATE_KEY"); + std::env::remove_var("BUZZ_AUTH_TAG"); + let state = build_app_state(); + *state.keys.lock().map_err(|_| "identity lock")? = keys(&root, "owner")?; + *state.relay_url_override.lock().map_err(|_| "relay lock")? = Some(args[3].clone()); + let mut context = crate::native_context(); + context.config_mut().identifier = format!("xyz.block.buzz.start-tracer.{scope}.{role}"); + context.config_mut().app.windows.clear(); + let app = tauri::Builder::default() + .manage(state) + .build(context) + .map_err(|_| "native app build failed")?; + let handle = app.handle().clone(); + let relay = args[3].clone(); + // Use run_return: Wry's non-returning run can discard AppHandle::exit's + // requested code. Success also requires that the trace actually completed. + let completed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let task_completed = completed.clone(); + tauri::async_runtime::spawn(async move { + let result = if role.starts_with("move-") { + move_trace::trace(&handle, &role, &root, &relay).await + } else { + trace(&handle, &role, &root, &relay).await + }; + match result { + Ok(()) => { + task_completed.store(true, std::sync::atomic::Ordering::Release); + handle.exit(0); + } + Err(error) => { + eprintln!("Start tracer failed: {error}"); + handle.exit(1); + } + } + }); + let exit_code = app.run_return(|_, _| {}); + if exit_code == 0 && completed.load(std::sync::atomic::Ordering::Acquire) { + Ok(()) + } else { + Err("tracer did not complete successfully; inspect result files and logs".into()) + } +} + +fn keys(root: &Path, name: &str) -> Result { + Keys::parse( + &std::fs::read_to_string(root.join(format!("{name}.key"))) + .map_err(|_| "missing fixture key")?, + ) + .map_err(|_| "invalid fixture key".into()) +} +fn save(root: &Path, name: &str, value: &Value) -> Result<(), String> { + managed_agents::atomic_write_json_restricted( + &root.join(name), + &serde_json::to_vec_pretty(value).map_err(|_| "serialize evidence")?, + ) + .map_err(|_| "save evidence".into()) +} +async fn trace( + app: &tauri::AppHandle, + role: &str, + root: &Path, + relay_url: &str, +) -> Result<(), String> { + let state = app.state::(); + let owner = state.signing_keys()?; + let owner_hex = owner.public_key().to_hex(); + let registration = commands::create_host_registration(state.clone(), owner_hex.clone()).await?; + let reg = Event::from_json(registration.to_string()).map_err(|_| "registration")?; + relay::submit_signed_event_with_keys(®, &state, &owner, None).await?; + println!("PASS {role} registered {}", reg.id); + if role == "destination" { + let agent = keys(root, "agent")?; + let record: managed_agents::ManagedAgentRecord = serde_json::from_value(json!({ + "pubkey": agent.public_key().to_hex(), "name":"Start tracer fixture", "private_key_nsec":agent.secret_key().to_bech32().map_err(|_| "fixture key encoding")?, + "auth_tag":buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").map_err(|_| "fixture owner attestation")?, + "relay_url":relay_url, "acp_command":"buzz-acp", "agent_command":"buzz-agent", "agent_command_override":"buzz-agent", "agent_args":[], "mcp_command":"buzz-dev-mcp", + "turn_timeout_seconds":320, "system_prompt":"Synthetic local tracer. Do not perform work.", "model":"fixture", "provider":"openai", + "env_vars":{"OPENAI_COMPAT_API_KEY":"synthetic-not-a-credential", "OPENAI_COMPAT_BASE_URL":"http://127.0.0.1:18991/v1"}, + "start_on_app_launch":false, "auto_restart_on_config_change":false, "created_at":"2026-08-31T00:00:00Z", "updated_at":"2026-08-31T00:00:00Z", + "last_started_at":null, "last_stopped_at":null, "last_exit_code":null, "last_error":null + })).map_err(|_| "fixture record schema mismatch")?; + managed_agents::save_managed_agents(app, &[record])?; + let config = commands::inspect_local_execution_config( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + agent.public_key().to_hex(), + ) + .await?; + save( + root, + "destination.json", + &json!({"registration":registration,"agent":agent.public_key().to_hex(),"config":config}), + )?; + for _ in 0..120 { + let snapshot = commands::pump_host_start( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + ) + .await?; + save( + root, + "destination-progress.json", + &serde_json::to_value(snapshot).map_err(|_| "snapshot")?, + )?; + if root.join("finish").exists() { + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + // Only the child owned by this fixture app; never broad PID sweeps. + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|_| "runtime lock")?; + for runtime in runtimes.values_mut() { + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } + println!( + "PASS destination fixture loop ended; root children reaped (not a Stop certificate)" + ); + return Ok(()); + } + let destination: Value = serde_json::from_slice( + &std::fs::read(root.join("destination.json")).map_err(|_| "destination not ready")?, + ) + .map_err(|_| "destination fixture invalid")?; + let text = |field: &Value| { + field + .as_str() + .map(str::to_owned) + .ok_or_else(|| "destination field missing".to_string()) + }; + let agent = text(&destination["agent"])?; + let operation = commands::queue_host_start( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + destination["registration"].clone(), + agent.clone(), + text(&destination["config"]["runtime"])?, + text(&destination["config"]["revision"])?, + None, + ) + .await?; + println!("PASS source queued immutable operation {operation}"); + for _ in 0..90 { + let snapshot = serde_json::to_value( + commands::pump_host_start( + app.clone(), + state.clone(), + owner_hex.clone(), + relay_url.into(), + ) + .await?, + ) + .map_err(|_| "snapshot")?; + save(root, "source-progress.json", &snapshot)?; + let spawned = snapshot["operations"].as_array().is_some_and(|ops| { + ops.iter() + .any(|op| op["operation"] == operation && op["status"] == "spawned") + }); + if spawned { + let runs = commands::get_presence_runs( + state.clone(), + owner_hex.clone(), + relay_url.into(), + vec![agent.clone()], + ) + .await?; + let value = serde_json::to_value(&runs).map_err(|_| "presence")?; + save(root, "presence.json", &value)?; + // Public ACP run ID must equal the native Start generation. + let destination_registration = + Event::from_json(destination["registration"].to_string()) + .map_err(|_| "destination registration")?; + let destination_host = buzz_core_pkg::host::validate(&destination_registration)? + .host + .to_hex(); + if runs.get(&agent).is_some_and(|runs| { + runs.iter().any(|run| { + run.run == operation + && run.location.as_ref().is_some_and(|location| { + location.host == destination_host && !location.label.is_empty() + }) + }) + }) { + save( + root, + "source-success.json", + &json!({"operation":operation,"snapshot":snapshot,"presence":value,"physical_hosts":1,"executors":2,"provider":"synthetic fixture; no model turn asserted"}), + )?; + println!("PASS source -> destination spawn -> signed correlated receipt -> public live run {operation}"); + return Ok(()); + } + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + Err("timed out awaiting spawned receipt and matching live run; inspect private fixture progress".into()) +} diff --git a/desktop/src-tauri/src/host_start_tracer/keychain.rs b/desktop/src-tauri/src/host_start_tracer/keychain.rs new file mode 100644 index 00000000000..aa521751f6f --- /dev/null +++ b/desktop/src-tauri/src/host_start_tracer/keychain.rs @@ -0,0 +1,88 @@ +//! Tracer-only native keychain pinned to a fixture file. Never changes the user's +//! default keychain/search list, and never substitutes an in-memory secret store. +use keyring::{ + credential::{Credential, CredentialApi, CredentialBuilderApi}, + macos::decode_error, +}; +use security_framework::os::macos::{ + keychain::{CreateOptions, SecKeychain}, + passwords::find_generic_password, +}; +use std::{ + any::Any, + path::{Path, PathBuf}, +}; + +pub(super) fn install(home: &Path) -> Result<(), String> { + let path = home.join("tracer.keychain"); + // Persist only inside the restricted fixture, never in source or evidence. + let password_path = home.join("keychain-password"); + let password = if path.exists() { + std::fs::read_to_string(&password_path).map_err(|_| "fixture keychain password missing")? + } else { + let password = nostr::Keys::generate().secret_key().to_secret_hex(); + crate::managed_agents::atomic_write_json_restricted(&password_path, password.as_bytes()) + .map_err(|_| "fixture keychain password write failed")?; + password + }; + let mut chain = if path.exists() { + SecKeychain::open(&path) + } else { + CreateOptions::new().password(&password).create(&path) + } + .map_err(|_| "fixture keychain create/open failed")?; + chain + .unlock(Some(&password)) + .map_err(|_| "fixture keychain unlock failed")?; + keyring::set_default_credential_builder(Box::new(Builder(path))); + Ok(()) +} +struct Builder(PathBuf); +impl CredentialBuilderApi for Builder { + fn build( + &self, + _: Option<&str>, + service: &str, + user: &str, + ) -> keyring::Result> { + Ok(Box::new(Entry { + path: self.0.clone(), + service: service.into(), + user: user.into(), + })) + } + fn as_any(&self) -> &dyn Any { + self + } +} +struct Entry { + path: PathBuf, + service: String, + user: String, +} +impl Entry { + fn chain(&self) -> keyring::Result { + SecKeychain::open(&self.path).map_err(decode_error) + } +} +impl CredentialApi for Entry { + fn set_secret(&self, secret: &[u8]) -> keyring::Result<()> { + self.chain()? + .set_generic_password(&self.service, &self.user, secret) + .map_err(decode_error) + } + fn get_secret(&self) -> keyring::Result> { + let (bytes, _) = find_generic_password(Some(&[self.chain()?]), &self.service, &self.user) + .map_err(decode_error)?; + Ok(bytes.to_owned()) + } + fn delete_credential(&self) -> keyring::Result<()> { + let (_, item) = find_generic_password(Some(&[self.chain()?]), &self.service, &self.user) + .map_err(decode_error)?; + item.delete(); + Ok(()) + } + fn as_any(&self) -> &dyn Any { + self + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2dde312d779..dc90c658b2e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,9 @@ #![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth. +#[cfg(feature = "remote-start-tracer")] +mod host_start_tracer; +#[cfg(feature = "remote-start-tracer")] +#[doc(hidden)] +pub use host_start_tracer::run as run_host_start_tracer; mod app_menu; mod app_state; mod archive; @@ -611,6 +616,14 @@ pub fn run() { build_observer_control_event, create_auth_event, nip44_encrypt_to_self, + get_local_host, + get_host_history_page, + create_host_registration, + create_host_report, + create_host_presence, + get_presence_runs, + inspect_host_registration, + decode_host_report, nip44_decrypt_from_self, get_channels, get_open_channel_directory, @@ -689,6 +702,11 @@ pub fn run() { list_managed_agent_runtimes, start_managed_agent_runtime, stop_managed_agent_runtime, + commands::execute_host_command, + commands::queue_host_start, + commands::queue_host_move, + commands::pump_host_start, + commands::inspect_local_execution_config, restart_managed_agent_runtime, reconcile_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, @@ -861,7 +879,7 @@ pub fn run() { #[cfg(target_os = "macos")] tray_menu::update_tray_agent_activity, ]) - .build(tauri::generate_context!()) + .build(native_context()) .expect("error while building tauri application"); let shutdown_done = Arc::new(AtomicBool::new(false)); @@ -935,3 +953,8 @@ pub fn run() { _ => {} }); } + +// One macro expansion per binary: macOS embeds a single Info.plist symbol. +fn native_context() -> tauri::Context { + tauri::generate_context!() +} diff --git a/desktop/src-tauri/src/managed_agents/execution.rs b/desktop/src-tauri/src/managed_agents/execution.rs new file mode 100644 index 00000000000..4d7b065a10a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/execution.rs @@ -0,0 +1,571 @@ +//! Native transition seam for authenticated host commands. The relay receiver/UI +//! is deliberately not enabled yet. Only destination-local provisioned configs +//! are supported: no source workspace, environment, loopback endpoint or key copy. +use buzz_core_pkg::host_execution::{Action, Command, Outcome}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Manager}; + +use super::{ + execution_ledger::{Begin, Entry, Ledger}, + *, +}; + +#[derive(Serialize)] +pub(crate) struct LocalExecutionConfig { + pub runtime: String, + pub revision: String, +} + +pub(super) fn config_revision( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &GlobalAgentConfig, + teams: &[TeamRecord], + descriptor: &super::readiness::EffectiveHarnessDescriptor, +) -> Result { + // Hash in memory; never persist/return these secret-bearing input bytes. + let bytes = serde_json::to_vec(&( + env!("CARGO_PKG_VERSION"), + record, + personas, + global, + teams, + &descriptor.command, + &descriptor.args, + &descriptor.env, + )) + .map_err(|_| "cannot fingerprint destination config")?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +pub(crate) fn local_execution_config( + app: &AppHandle, + record: &ManagedAgentRecord, +) -> Result { + execution_config(app, record, true) +} + +fn execution_config( + app: &AppHandle, + record: &ManagedAgentRecord, + remote: bool, +) -> Result { + local_execution_prerequisites(record)?; + let personas = load_personas(app)?; + let teams = load_teams(app)?; + let global = load_global_agent_config(app)?; + let descriptor = resolve_effective_harness_descriptor(record, &personas, &global)?; + let runtime = known_acp_runtime(&descriptor.command); + let revision = config_revision(record, &personas, &global, &teams, &descriptor)?; + if !remote { + // Ordinary local launch keeps its existing readiness/custom-runtime and + // agents-everywhere semantics. Spawn still rechecks this exact revision. + return Ok(LocalExecutionConfig { + runtime: runtime.map_or("custom", |runtime| runtime.id).into(), + revision, + }); + } + let runtime = runtime.ok_or("destination runtime is not in the Rust catalog")?; + let effective = effective_config::resolve_effective_config(record, &personas, &global) + .require_resolved()?; + // Destination-local mesh preflight needs its own readiness gate. Do not + // mistake a source machine's loopback endpoint for a portable provider. + if effective.relay_mesh_model_id().is_some() { + return Err("host command mesh preflight is not supported yet".into()); + } + if !matches!( + agent_readiness(&resolve_effective_agent_env( + record, + &personas, + Some(runtime), + &global + )), + AgentReadiness::Ready + ) { + return Err("destination agent configuration is not ready".into()); + } + Ok(LocalExecutionConfig { + runtime: runtime.id.into(), + revision, + }) +} + +// Shared by inspection/advertisement and the launch preflight. Only key +// availability is checked, exactly as at spawn; no key parsing/export is needed. +fn local_execution_prerequisites(record: &ManagedAgentRecord) -> Result<(), String> { + if record.backend != BackendKind::Local { + return Err("destination agent is not locally provisioned".into()); + } + if let Some(error) = super::storage::spawn_key_refusal(record) { + return Err(error); + } + Ok(()) +} + +/// Remote execution requires an explicit verified owner attestation, even for +/// legacy local records. A local key in the store is not a remote launch grant. +pub(crate) fn execution_agent_owner( + record: &ManagedAgentRecord, + owner: &str, +) -> Result<(), String> { + let agent = + nostr::PublicKey::from_hex(&record.pubkey).map_err(|_| "invalid provisioned agent")?; + let tag = record + .auth_tag + .as_deref() + .ok_or("destination agent needs owner setup")?; + let issuer = buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &agent) + .map_err(|_| "invalid destination ownership")?; + if issuer.to_hex() != owner { + return Err("destination agent belongs to another owner".into()); + } + Ok(()) +} + +fn ledger(app: &AppHandle, key: &ManagedAgentRuntimeKey, owner: &str) -> Result { + if !buzz_core_pkg::host_execution::hex_id(owner, 64) { + return Err("invalid execution owner".into()); + } + Ledger::open( + &managed_agents_base_dir(app)?.join("execution-ledger"), + &format!("{owner}__{}", key.runtime_id()), + ) +} + +pub(super) fn legacy_spawn_guard( + app: &AppHandle, + record: &ManagedAgentRecord, + relay: &str, + owner: Option<&str>, +) -> Result { + let owner = owner.ok_or("managed launch requires an owner")?; + let key = ManagedAgentRuntimeKey::new(&record.pubkey, relay)?; + let ledger = ledger(app, &key, owner)?; + if ledger.is_fenced() { + return Err( + "placement is controlled by a durable execution operation; explicit Start required" + .into(), + ); + } + Ok(ledger) +} + +/// Called only after signature, live registration, destination and runtime +/// compatibility checks. Caller retains owner authority, never a host-only login. +pub(crate) fn execute_host_operation( + app: &AppHandle, + owner: &str, + command_id: &str, + request: &Command, + compatible_runtime: bool, +) -> Result { + execute_operation( + app, + owner, + command_id, + request, + compatible_runtime, + Admission::Remote, + ) +} + +enum Admission<'a> { + Remote, + // Exact predecessor captured by the explicit local action; rechecked while + // holding both the transition lock and OS journal lock, never auto-reconcile. + Local { predecessor: &'a str }, +} + +fn local_start_predecessor(entry: &Entry) -> Result<(), String> { + match (&entry.request.action, &entry.outcome) { + (Action::Stop { .. }, Outcome::Stopped) | (Action::Start { .. }, Outcome::Rejected) => { + Ok(()) + } + _ => Err( + "Previous execution is not proven stopped or rejected; replacement remains blocked" + .into(), + ), + } +} + +impl Admission<'_> { + fn validate_predecessor(&self, ledger: &Ledger, request: &Command) -> Result<(), String> { + if let Admission::Local { predecessor } = self { + let current = ledger + .current() + .ok_or("local Start predecessor disappeared")?; + if current.command_id != *predecessor || !matches!(request.action, Action::Start { .. }) + { + return Err("local Start predecessor changed; refresh runtime status".into()); + } + local_start_predecessor(current)?; + } + Ok(()) + } + + fn admits_record(&self, record: &ManagedAgentRecord, owner: &str, relay: &str) -> bool { + match self { + Self::Local { .. } => local_execution_prerequisites(record).is_ok(), + Self::Remote => { + buzz_core_pkg::relay::normalize_relay_url(&record.relay_url) + .ok() + .as_deref() + == Some(relay) + && execution_agent_owner(record, owner).is_ok() + } + } + } +} + +fn execute_operation( + app: &AppHandle, + owner: &str, + command_id: &str, + request: &Command, + compatible_runtime: bool, + admission: Admission<'_>, +) -> Result { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|_| "runtime transition lock unavailable")?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|_| "agent store lock unavailable")?; + if state + .shutdown_started + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("desktop is shutting down".into()); + } + crate::relay::assert_expected_signer( + Some(owner), + &state.signing_keys()?.public_key().to_hex(), + )?; + crate::relay::assert_expected_relay_scope( + Some(&request.relay), + &crate::relay::relay_api_base_url_with_override(&state), + )?; + let key = ManagedAgentRuntimeKey::new(&request.agent, &request.relay)?; + let mut ledger = ledger(app, &key, owner)?; + // Retry is resolved before inspecting current config/process state. Config + // drift after success must not turn an ACK-loss retry into another launch. + if let Some(entry) = ledger.replay(command_id, request)? { + return Ok(entry); + } + // Historical commands may only read the immutable ledger. An expired + // request without prior intent never reaches launch or Stop. + if request.expires_at <= nostr::Timestamp::now().as_secs() { + return Err("execution command expired without a recorded outcome".into()); + } + admission.validate_predecessor(&ledger, request)?; + let mut records = load_managed_agents(app)?; + if matches!(request.action, Action::Start { .. }) + && !records + .iter() + .any(|r| r.pubkey == request.agent && admission.admits_record(r, owner, &request.relay)) + { + ledger.begin(command_id, request)?; + return ledger.finish(&request.operation, Outcome::Rejected); + } + let record = find_managed_agent_mut(&mut records, &request.agent)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|_| "runtime lock unavailable")?; + if let Action::Stop { run } = &request.action { + // Validate before persisting a new Stop fence. A stale clicked run must + // neither kill nor take ownership of a newer local placement. + let selected = runtimes + .get(&key) + .ok_or("selected run is not tracked; stop outcome unknown")?; + if !exact_generation_matches(&selected.start_nonce, run) { + return Err("selected generation is no longer current".into()); + } + } + if let Begin::Replay(entry) = ledger.begin(command_id, request)? { + return Ok(entry); + } + match &request.action { + Action::Start { runtime, revision } => { + // No adoption or teardown of a peer, even one whose root has exited. + // A legacy receipt (including corrupt data) is an unresolved conflict. + let receipt_path = managed_agents_base_dir(app)? + .join("agent-pids") + .join(format!("{}.json", key.runtime_id())); + let preflight = execution_config(app, record, matches!(admission, Admission::Remote)); + let compatible = preflight + .is_ok_and(|config| config.runtime == *runtime && config.revision == *revision); + if !compatible_runtime + || !compatible + || runtimes.contains_key(&key) + || receipt_path + .try_exists() + .map_err(|_| "cannot inspect prior receipt")? + { + return ledger.finish(&request.operation, Outcome::Rejected); + } + let process = match super::runtime::spawn_agent_child_for_run( + app, + record, + &key.relay_url, + false, + Some(owner), + Some((&request.operation, revision)), + ) { + Ok(process) => process, + // spawn_agent_child_for_run returns Err only before a child is + // created (including OS spawn failure); after spawn it always + // returns the retained Child. Never classify post-spawn receipt, + // startup timeout or root-exit failures as definite rejection. + Err(_) => return ledger.finish(&request.operation, Outcome::Rejected), + }; + let now = crate::util::now_iso(); + let receipt = ManagedAgentRuntimeReceipt { + key: key.clone(), + pid: process.child.id(), + desktop_instance_id: current_instance_id(app), + started_at: now.clone(), + run_id: Some(process.start_nonce.clone()), + }; + if write_agent_runtime_receipt(app, &receipt).is_err() { + // Preserve a possibly surviving child in memory, even if durable + // observation failed. Never report definite failure after spawn. + runtimes.insert(key, ManagedAgentPairRuntime::starting(process)); + return ledger.finish(&request.operation, Outcome::Unknown); + } + // Process observation only: not Ready, not online, not an LLM turn. + record.updated_at = now.clone(); + record.last_started_at = Some(now); + record.last_stopped_at = None; + record.last_error = None; + record.runtime_pid = None; + runtimes.insert(key, ManagedAgentPairRuntime::starting(process)); + if save_managed_agents(app, &records).is_err() { + return ledger.finish(&request.operation, Outcome::Unknown); + } + ledger.finish(&request.operation, Outcome::Spawned) + } + Action::Stop { run } => { + let Some(runtime) = runtimes.get_mut(&key) else { + // Absence, legacy PID receipts and presence expiry are not proof. + return ledger.finish(&request.operation, Outcome::Unknown); + }; + if !exact_generation_matches(&runtime.start_nonce, run) { + // A delayed selected-run Stop must not kill this newer peer. + return ledger.finish(&request.operation, Outcome::Unknown); + } + let actual = runtime.start_nonce.clone(); + if stop_selected_generation(&mut runtime.child, &actual, run).is_err() { + return ledger.finish(&request.operation, Outcome::Unknown); + } + // Root exit alone cannot certify separately grouped descendants. + // Only a supported, authenticated same-generation owned-work proof + // permits replacement. Missing/invalid evidence remains fenced. + let proof_path = stop_proof_path(&runtime.log_path, run); + let successful_root = runtime + .child + .try_wait() + .ok() + .flatten() + .is_some_and(|status| status.success()); + let outcome = if successful_root + && verified_stop_proof(&proof_path, &key.pubkey, &key.relay_url, run) + { + Outcome::Stopped + } else { + Outcome::RootExited + }; + let result = ledger.finish(&request.operation, outcome)?; + let _ = std::fs::remove_file(proof_path); + runtimes.remove(&key); + remove_agent_runtime_receipt(app, &key); + state.clear_agent_session_cache(&key); + record.runtime_pid = None; + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + save_managed_agents(app, &records)?; + Ok(result) + } + } +} + +/// Ordinary Desktop Stop enters the same generation fence, proof and ledger +/// authority as a host Stop. The clicked nonce is mandatory, never read afresh. +pub(super) fn stop_local_selected_run( + app: &AppHandle, + pubkey: &str, + relay: &str, + selected_run: Option<&str>, +) -> Result<(), String> { + let run = selected_run + .filter(|run| buzz_core_pkg::host_execution::hex_id(run, 32)) + .ok_or("Exact Stop unsupported without a selected run; refresh runtime status")?; + let state = app.state::(); + let owner = state.signing_keys()?.public_key().to_hex(); + let key = ManagedAgentRuntimeKey::new(pubkey, relay)?; + let operation = hex::encode(Sha256::digest(format!( + "buzz.desktop.stop.v1\n{owner}\n{}\n{pubkey}\n{run}", + key.relay_url + )))[..32] + .to_owned(); + // Reuse the first immutable local request (including deadline) on retry. + let prior = ledger(app, &key, &owner)?.operation(&operation).cloned(); + let (command_id, request) = match prior { + Some(entry) => (entry.command_id, entry.request), + None => { + let request = Command { + v: 1, + operation, + relay: key.relay_url, + agent: pubkey.into(), + expires_at: nostr::Timestamp::now().as_secs() + + buzz_core_pkg::host_execution::COMMAND_TTL, + action: Action::Stop { run: run.into() }, + }; + let bytes = serde_json::to_vec(&request).map_err(|_| "invalid local Stop")?; + (hex::encode(Sha256::digest(bytes)), request) + } + }; + let entry = execute_host_operation(app, &owner, &command_id, &request, false)?; + if entry.outcome != Outcome::Stopped { + return Err(format!( + "Selected Stop unconfirmed ({:?}); replacement remains blocked", + entry.outcome + )); + } + Ok(()) +} + +/// Explicit ordinary Start after an exact Stop or a definite rejected Start +/// uses the same journal, with ordinary local admission rather than remote +/// provisioning grants. Automatic reconciliation never calls this. +pub(crate) fn start_after_exact_stop( + app: &AppHandle, + pubkey: &str, + relay: &str, + owner: &str, +) -> Result { + let key = ManagedAgentRuntimeKey::new(pubkey, relay)?; + let prior = ledger(app, &key, owner)?.current().cloned(); + let Some(prior) = prior else { + return Ok(false); + }; + local_start_predecessor(&prior)?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("agent not found")?; + let config = execution_config(app, record, false)?; + let request = Command { + v: 1, + operation: uuid::Uuid::new_v4().simple().to_string(), + relay: key.relay_url, + agent: pubkey.into(), + expires_at: nostr::Timestamp::now().as_secs() + buzz_core_pkg::host_execution::COMMAND_TTL, + action: Action::Start { + runtime: config.runtime, + revision: config.revision, + }, + }; + let bytes = serde_json::to_vec(&request).map_err(|_| "invalid local Start")?; + let result = execute_operation( + app, + owner, + &hex::encode(Sha256::digest(bytes)), + &request, + true, + Admission::Local { + predecessor: &prior.command_id, + }, + )?; + if result.outcome != Outcome::Spawned { + return Err("Explicit Start not confirmed; inspect destination setup".into()); + } + Ok(true) +} + +pub(super) fn stop_proof_path(log: &std::path::Path, run: &str) -> std::path::PathBuf { + log.with_extension(format!("stop-{run}.json")) +} + +fn verified_stop_proof(path: &std::path::Path, agent: &str, relay: &str, run: &str) -> bool { + use std::io::Read; + let Ok(file) = std::fs::File::open(path) else { + return false; + }; + let mut bytes = Vec::new(); + if file.take(4097).read_to_end(&mut bytes).is_err() || bytes.len() > 4096 { + return false; + } + serde_json::from_slice::(&bytes) + .is_ok_and(|proof| buzz_core_pkg::owned_stop::verify(&proof, agent, relay, run).is_ok()) +} + +fn stop_selected_generation( + child: &mut std::process::Child, + actual: &str, + expected: &str, +) -> Result<(), String> { + if !exact_generation_matches(actual, expected) { + return Err("selected generation is no longer current".into()); + } + super::runtime::terminate_exact_owned_group(child) +} + +#[cfg(all(test, unix))] +#[path = "execution_stop_process_tests.rs"] +mod stop_process_tests; + +fn exact_generation_matches(actual: &str, expected: &str) -> bool { + buzz_core_pkg::host_execution::hex_id(expected, 32) && actual == expected +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn keyless_record_is_not_advertised_as_locally_provisioned() { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "synthetic-agent", "name": "test-agent", "private_key_nsec": "", + "relay_url": "wss://relay.example", "acp_command": "buzz-acp", + "agent_command": "buzz-agent", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 320, "created_at": "", "updated_at": "" + })) + .unwrap(); + assert_eq!( + local_execution_prerequisites(&record).unwrap_err(), + super::super::storage::spawn_key_refusal(&record).unwrap() + ); + // Availability only: this preflight must not inspect or export key bytes. + record.private_key_nsec = "synthetic-nonempty-placeholder".into(); + assert!(local_execution_prerequisites(&record).is_ok()); + // A legacy pin and absent attestation do not revoke ordinary local + // community-pair authority. They still deny remote destination Start. + let local = Admission::Local { + predecessor: "unused", + }; + assert!(local.admits_record(&record, &"aa".repeat(32), "wss://other.example")); + assert!(!Admission::Remote.admits_record(&record, &"aa".repeat(32), "wss://other.example")); + assert!(!Admission::Remote.admits_record(&record, &"aa".repeat(32), "wss://relay.example")); + } + + #[test] + fn stop_fences_successor_and_malformed_or_legacy_generation() { + assert!(exact_generation_matches(&"aa".repeat(16), &"aa".repeat(16))); + assert!(!exact_generation_matches( + &"aa".repeat(16), + &"bb".repeat(16) + )); + assert!(!exact_generation_matches("", "")); + assert!(!exact_generation_matches("legacy", "legacy")); + } +} + +#[cfg(test)] +#[path = "execution_local_recovery_tests.rs"] +mod local_recovery_tests; diff --git a/desktop/src-tauri/src/managed_agents/execution_ledger.rs b/desktop/src-tauri/src/managed_agents/execution_ledger.rs new file mode 100644 index 00000000000..c1eaaf59dfd --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/execution_ledger.rs @@ -0,0 +1,339 @@ +//! Secret-free, durable per-placement execution journal. Callers authenticate and +//! revalidate authorization BEFORE opening it. No replay ever repeats a side +//! effect, including after a crash in the intent→spawn/stop window. +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, +}; + +use buzz_core_pkg::host_execution::{hex_id, Action, Command, Outcome}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Entry { + pub command_id: String, + pub request: Command, + pub outcome: Outcome, + pub observed_at: u64, +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Journal { + entries: BTreeMap, + // A placement stays fenced even after a confirmed Stop. Only a new explicit + // durable Start may release it; config edits/auto-reconcile may not resurrect. + current: Option, +} + +pub(crate) enum Begin { + Execute, + Replay(Entry), +} + +/// An OS file lock also serializes two controller processes sharing one store. +/// Never unlink the lock file: doing so would permit locks on different inodes. +pub(crate) struct Ledger { + _lock: File, + path: PathBuf, + journal: Journal, +} + +impl Ledger { + pub(crate) fn open(directory: &Path, placement: &str) -> Result { + if placement.is_empty() + || !placement + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err("invalid execution placement".into()); + } + fs::create_dir_all(directory).map_err(|_| "execution journal unavailable")?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let lock = options + .open(directory.join(format!("{placement}.lock"))) + .map_err(|_| "execution lock unavailable")?; + lock.try_lock().map_err(|_| "execution placement busy")?; + let path = directory.join(format!("{placement}.json")); + let journal: Journal = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|_| "execution journal corrupt")?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Journal::default(), + Err(_) => return Err("execution journal unreadable".into()), + }; + if journal.entries.len() > 4096 + || journal + .current + .as_ref() + .is_some_and(|id| !journal.entries.contains_key(id)) + || (journal.current.is_none() && !journal.entries.is_empty()) + || journal.entries.iter().any(|(id, entry)| { + id != &entry.request.operation + || !hex_id(&entry.command_id, 64) + || entry.request.validate().is_err() + }) + { + return Err("execution journal invariants invalid".into()); + } + Ok(Self { + _lock: lock, + path, + journal, + }) + } + + fn save(&self) -> Result<(), String> { + let bytes = serde_json::to_vec(&self.journal) + .map_err(|_| "execution journal serialization failed")?; + super::atomic_write_json_restricted(&self.path, &bytes) + .map_err(|_| "execution journal write failed")?; + // Atomic rename is not a durable intent unless directory metadata is + // synced too. On unsupported filesystems fail before a side effect. + #[cfg(unix)] + { + let parent = self.path.parent().ok_or("invalid journal directory")?; + File::open(parent) + .and_then(|dir| dir.sync_all()) + .map_err(|_| "execution journal sync failed")?; + } + Ok(()) + } + + pub(crate) fn current(&self) -> Option<&Entry> { + self.journal + .current + .as_ref() + .and_then(|id| self.journal.entries.get(id)) + } + + pub(crate) fn operation(&self, id: &str) -> Option<&Entry> { + self.journal.entries.get(id) + } + + pub(crate) fn replay( + &self, + command_id: &str, + request: &Command, + ) -> Result, String> { + if let Some(entry) = self.journal.entries.get(&request.operation) { + if entry.command_id != command_id || entry.request != *request { + return Err("operation ID already belongs to another command".into()); + } + // Accepted is deliberately not resumed: creation/termination could + // have happened before the result write. Reconciliation must prove + // the old generation, not manufacture a replacement. + let mut result = entry.clone(); + if result.outcome == Outcome::Accepted { + result.outcome = Outcome::Unknown; + } + return Ok(Some(result)); + } + Ok(None) + } + + pub(crate) fn begin(&mut self, command_id: &str, request: &Command) -> Result { + request.validate()?; + if !hex_id(command_id, 64) { + return Err("invalid execution command ID".into()); + } + if let Some(entry) = self.replay(command_id, request)? { + return Ok(Begin::Replay(entry)); + } + if self.journal.entries.len() >= 4096 { + return Err("execution journal requires archival".into()); + } + if let Some(current) = self + .journal + .current + .as_ref() + .and_then(|id| self.journal.entries.get(id)) + { + if request.agent != current.request.agent || request.relay != current.request.relay { + return Err("execution placement binding mismatch".into()); + } + match &request.action { + Action::Start { .. } + if current.outcome != Outcome::Stopped + && current.outcome != Outcome::Rejected => + { + return Err("previous execution is not proven stopped".into()); + } + Action::Stop { run } if run != current.request.run() => { + return Err("stop generation does not match placement fence".into()); + } + _ => {} + } + } + self.journal.entries.insert( + request.operation.clone(), + Entry { + command_id: command_id.into(), + request: request.clone(), + outcome: Outcome::Accepted, + observed_at: nostr::Timestamp::now().as_secs(), + }, + ); + self.journal.current = Some(request.operation.clone()); + self.save()?; + Ok(Begin::Execute) + } + + pub(crate) fn finish(&mut self, operation: &str, outcome: Outcome) -> Result { + if self.journal.current.as_deref() != Some(operation) { + return Err("stale execution result".into()); + } + let entry = self + .journal + .entries + .get_mut(operation) + .ok_or("unknown execution operation")?; + // A terminal state cannot be overwritten by a late asynchronous result. + if matches!(entry.outcome, Outcome::Stopped | Outcome::Rejected) && entry.outcome != outcome + { + return Err("execution result is terminal".into()); + } + if outcome == Outcome::Rejected && entry.outcome != Outcome::Accepted { + return Err("cannot reject after a possible side effect".into()); + } + let allowed = match entry.request.action { + Action::Start { .. } => matches!( + outcome, + Outcome::Spawned + | Outcome::Listening + | Outcome::Ready + | Outcome::Rejected + | Outcome::Unknown + ), + Action::Stop { .. } => matches!( + outcome, + Outcome::RootExited | Outcome::Stopped | Outcome::Unknown + ), + }; + if !allowed { + return Err("invalid execution transition".into()); + } + entry.outcome = outcome; + entry.observed_at = nostr::Timestamp::now().as_secs(); + let result = entry.clone(); + self.save()?; + Ok(result) + } + + /// All legacy/config-driven starts must fail while a durable placement fence + /// exists. The owning durable Start holds this lock through launch instead. + pub(crate) fn is_fenced(&self) -> bool { + self.journal.current.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn request(operation: &str, action: Action) -> Command { + Command { + v: 1, + operation: operation.repeat(16), + relay: "wss://relay.example".into(), + agent: nostr::Keys::generate().public_key().to_hex(), + expires_at: 200, + action, + } + } + fn start() -> Command { + request( + "aa", + Action::Start { + runtime: "goose".into(), + revision: "bb".repeat(32), + }, + ) + } + #[test] + fn crash_and_ack_loss_never_repeat_start() { + let dir = tempfile::tempdir().unwrap(); + let req = start(); + let id = "cc".repeat(32); + { + let mut ledger = Ledger::open(dir.path(), "placement").unwrap(); + assert!(matches!(ledger.begin(&id, &req).unwrap(), Begin::Execute)); + assert!(Ledger::open(dir.path(), "placement").is_err()); + } + let mut ledger = Ledger::open(dir.path(), "placement").unwrap(); + assert!(matches!( + ledger.begin(&id, &req).unwrap(), + Begin::Replay(Entry { + outcome: Outcome::Unknown, + .. + }) + )); + let mut replacement = req.clone(); + replacement.operation = "dd".repeat(16); + assert!(ledger.begin(&"ee".repeat(32), &replacement).is_err()); + assert!(ledger.begin(&"ff".repeat(32), &req).is_err()); + ledger.finish(&req.operation, Outcome::Spawned).unwrap(); + assert!(matches!( + ledger.begin(&id, &req).unwrap(), + Begin::Replay(Entry { + outcome: Outcome::Spawned, + .. + }) + )); + } + #[test] + fn only_exact_confirmed_stop_allows_replacement_and_late_results_are_fenced() { + let dir = tempfile::tempdir().unwrap(); + let req = start(); + let mut ledger = Ledger::open(dir.path(), "placement").unwrap(); + ledger.begin(&"cc".repeat(32), &req).unwrap(); + ledger.finish(&req.operation, Outcome::Spawned).unwrap(); + let mut stop = req.clone(); + stop.operation = "dd".repeat(16); + stop.action = Action::Stop { + run: "ee".repeat(16), + }; + assert!(ledger.begin(&"ff".repeat(32), &stop).is_err()); + stop.action = Action::Stop { + run: req.operation.clone(), + }; + ledger.begin(&"ff".repeat(32), &stop).unwrap(); + ledger.finish(&stop.operation, Outcome::Unknown).unwrap(); + let mut next = req.clone(); + next.operation = "12".repeat(16); + assert!(ledger.begin(&"34".repeat(32), &next).is_err()); + ledger.finish(&stop.operation, Outcome::RootExited).unwrap(); + assert!( + ledger.begin(&"34".repeat(32), &next).is_err(), + "root exit is not a stop certificate" + ); + ledger.finish(&stop.operation, Outcome::Stopped).unwrap(); + assert!(ledger.is_fenced()); + ledger.begin(&"34".repeat(32), &next).unwrap(); + assert!(ledger.finish(&stop.operation, Outcome::Unknown).is_err()); + assert!(ledger.finish(&req.operation, Outcome::Ready).is_err()); + } + #[test] + fn corruption_and_traversal_fail_closed_and_ledger_contains_no_payload_secrets() { + let dir = tempfile::tempdir().unwrap(); + assert!(Ledger::open(dir.path(), "../escape").is_err()); + fs::write(dir.path().join("placement.json"), b"{").unwrap(); + assert!(Ledger::open(dir.path(), "placement").is_err()); + fs::write( + dir.path().join("placement.json"), + br#"{"entries":{},"current":"missing"}"#, + ) + .unwrap(); + assert!(Ledger::open(dir.path(), "placement").is_err()); + let mut ledger = Ledger::open(dir.path(), "other").unwrap(); + ledger.begin(&"cc".repeat(32), &start()).unwrap(); + let text = fs::read_to_string(dir.path().join("other.json")).unwrap(); + assert!(!text.contains("private_key")); + assert!(!text.contains("environment")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/execution_local_recovery_tests.rs b/desktop/src-tauri/src/managed_agents/execution_local_recovery_tests.rs new file mode 100644 index 00000000000..9571baf343f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/execution_local_recovery_tests.rs @@ -0,0 +1,172 @@ +//! Ordinary recovery uses the real persisted ledger, never clearing its fence. +use super::*; + +fn start(operation: &str) -> Command { + Command { + v: 1, + operation: operation.repeat(16), + relay: "wss://community-b.example".into(), + agent: nostr::Keys::generate().public_key().to_hex(), + expires_at: nostr::Timestamp::now().as_secs() + 120, + action: Action::Start { + runtime: "buzz-agent".into(), + revision: "ff".repeat(32), + }, + } +} + +#[test] +fn ordinary_rejected_start_recovers_after_reopen_without_losing_fence() { + let dir = tempfile::tempdir().unwrap(); + let initial = start("aa"); + let mut stop = initial.clone(); + stop.operation = "bb".repeat(16); + stop.action = Action::Stop { + run: initial.operation.clone(), + }; + let mut failed = initial.clone(); + failed.operation = "cc".repeat(16); + let stopped_id = "22".repeat(32); + let rejected_id = "33".repeat(32); + { + let mut ledger = Ledger::open(dir.path(), "placement").unwrap(); + ledger.begin(&"11".repeat(32), &initial).unwrap(); + ledger.finish(&initial.operation, Outcome::Spawned).unwrap(); + ledger.begin(&stopped_id, &stop).unwrap(); + ledger.finish(&stop.operation, Outcome::Stopped).unwrap(); + let admission = Admission::Local { + predecessor: &stopped_id, + }; + admission.validate_predecessor(&ledger, &failed).unwrap(); + ledger.begin(&rejected_id, &failed).unwrap(); + ledger.finish(&failed.operation, Outcome::Rejected).unwrap(); + } + let mut ledger = Ledger::open(dir.path(), "placement").unwrap(); + assert!( + ledger.is_fenced(), + "automatic/legacy launch remains blocked" + ); + let mut retry = failed.clone(); + retry.operation = "dd".repeat(16); + retry.action = Action::Start { + runtime: "buzz-agent".into(), + revision: "ee".repeat(32), + }; + assert!(Admission::Local { + predecessor: &stopped_id + } + .validate_predecessor(&ledger, &retry) + .is_err()); + let admission = Admission::Local { + predecessor: &rejected_id, + }; + admission.validate_predecessor(&ledger, &retry).unwrap(); + assert!(matches!( + ledger.begin(&"44".repeat(32), &retry).unwrap(), + Begin::Execute + )); + // A second callback captured before the first intent cannot create another + // attempt even when both started from the same definite rejection. + assert!(admission.validate_predecessor(&ledger, &retry).is_err()); + assert!(ledger.is_fenced()); + ledger.finish(&retry.operation, Outcome::Spawned).unwrap(); + assert_eq!( + ledger + .replay(&rejected_id, &failed) + .unwrap() + .unwrap() + .outcome, + Outcome::Rejected + ); + assert!(local_start_predecessor(ledger.current().unwrap()).is_err()); +} + +#[test] +fn ordinary_recovery_never_relabels_uncertainty_as_rejection() { + let request = start("aa"); + for outcome in [ + Outcome::Accepted, + Outcome::Unknown, + Outcome::RootExited, + Outcome::Spawned, + Outcome::Listening, + Outcome::Ready, + ] { + let mut entry = Entry { + command_id: "11".repeat(32), + request: request.clone(), + outcome: outcome.clone(), + observed_at: 1, + }; + assert!(local_start_predecessor(&entry).is_err(), "{outcome:?}"); + entry.request.action = Action::Stop { + run: "bb".repeat(16), + }; + assert!(local_start_predecessor(&entry).is_err(), "Stop {outcome:?}"); + } +} + +#[test] +fn recovery_serializes_across_controllers_and_rechecks_after_reopen() { + let dir = tempfile::tempdir().unwrap(); + let first = start("aa"); + let id = "11".repeat(32); + let mut ledger = Ledger::open(dir.path(), "placement").unwrap(); + ledger.begin(&id, &first).unwrap(); + ledger.finish(&first.operation, Outcome::Rejected).unwrap(); + let mut next = first.clone(); + next.operation = "bb".repeat(16); + let admission = Admission::Local { predecessor: &id }; + admission.validate_predecessor(&ledger, &next).unwrap(); + let path = dir.path().to_owned(); + assert!( + std::thread::spawn(move || Ledger::open(&path, "placement").is_err()) + .join() + .unwrap() + ); + ledger.begin(&"22".repeat(32), &next).unwrap(); + // Simulate a crash in the intent/spawn window, not a definite rejection. + drop(ledger); + let ledger = Ledger::open(dir.path(), "placement").unwrap(); + assert!(admission.validate_predecessor(&ledger, &next).is_err()); + assert!(local_start_predecessor(ledger.current().unwrap()).is_err()); + assert!(ledger.is_fenced()); + assert_eq!( + ledger + .replay(&"22".repeat(32), &next) + .unwrap() + .unwrap() + .outcome, + Outcome::Unknown + ); +} + +#[test] +fn post_spawn_or_root_exit_cannot_be_reclassified_for_local_retry() { + for outcome in [Outcome::Spawned, Outcome::Unknown] { + let dir = tempfile::tempdir().unwrap(); + let request = start("aa"); + let mut ledger = Ledger::open(dir.path(), "placement").unwrap(); + ledger.begin(&"11".repeat(32), &request).unwrap(); + ledger.finish(&request.operation, outcome.clone()).unwrap(); + assert!(ledger + .finish(&request.operation, Outcome::Rejected) + .is_err()); + drop(ledger); + let ledger = Ledger::open(dir.path(), "placement").unwrap(); + assert_eq!(ledger.current().unwrap().outcome, outcome); + assert!(local_start_predecessor(ledger.current().unwrap()).is_err()); + } + let dir = tempfile::tempdir().unwrap(); + let mut stop = start("aa"); + stop.action = Action::Stop { + run: "bb".repeat(16), + }; + let mut ledger = Ledger::open(dir.path(), "placement").unwrap(); + ledger.begin(&"11".repeat(32), &stop).unwrap(); + ledger.finish(&stop.operation, Outcome::RootExited).unwrap(); + assert!(ledger.finish(&stop.operation, Outcome::Rejected).is_err()); + drop(ledger); + let ledger = Ledger::open(dir.path(), "placement").unwrap(); + assert!(local_start_predecessor(ledger.current().unwrap()).is_err()); +} diff --git a/desktop/src-tauri/src/managed_agents/execution_stop_process_tests.rs b/desktop/src-tauri/src/managed_agents/execution_stop_process_tests.rs new file mode 100644 index 00000000000..d762c1744f6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/execution_stop_process_tests.rs @@ -0,0 +1,253 @@ +//! Actual native -> ACP -> agent -> MCP -> shell teardown, with fixture relay +//! and provider. No Desktop UI, command transport, real LLM or host certificate. +use super::stop_selected_generation; +use std::{ + fs, + os::unix::process::CommandExt, + path::Path, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +struct OwnedChild(Child); +impl Drop for OwnedChild { + fn drop(&mut self) { + // Unwinding must not kill an owner before its separately grouped + // descendants have had a chance to drain. + let _ = super::super::runtime::terminate_exact_owned_group(&mut self.0); + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn running(pid: u32) -> bool { + Command::new("/bin/kill") + .args(["-0", &pid.to_string()]) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() +} +fn wait_file(path: &Path) -> u32 { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Ok(text) = fs::read_to_string(path) { + if let Ok(pid) = text.trim().parse() { + return pid; + } + } + assert!(Instant::now() < deadline, "missing {}", path.display()); + std::thread::sleep(Duration::from_millis(25)); + } +} +fn parent(pid: u32) -> u32 { + let output = Command::new("/bin/ps") + .args(["-o", "ppid=", "-p", &pid.to_string()]) + .output() + .unwrap(); + String::from_utf8(output.stdout) + .unwrap() + .trim() + .parse() + .unwrap() +} +fn spawn_run(bin: &Path, dir: &Path, port: u32, generation: &str) -> OwnedChild { + let log = fs::File::create(dir.join("harness.log")).unwrap(); + let child = Command::new(bin.join("buzz-acp")) + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap()) + .env("HOME", dir) + .env("XDG_CONFIG_HOME", dir) + .env("TMPDIR", dir) + .env("BUZZ_RELAY_URL", format!("ws://127.0.0.1:{port}")) + // Public deterministic test key, never an operator identity. + .env("BUZZ_PRIVATE_KEY", "1".repeat(64)) + .env("BUZZ_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "fixture-not-a-secret") + .env("OPENAI_COMPAT_MODEL", "fixture") + .env( + "OPENAI_COMPAT_BASE_URL", + format!("http://127.0.0.1:{port}/v1"), + ) + .env("BUZZ_AGENT_HINTS_ENABLED", "false") + .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "600") + .env("BUZZ_MANAGED_AGENT_START_NONCE", generation) + .env( + "BUZZ_STOP_RECEIPT_PATH", + super::stop_proof_path(&dir.join("harness.log"), generation), + ) + .args([ + "--agent-command", + bin.join("buzz-agent").to_str().unwrap(), + "--agent-args", + "", + "--mcp-command", + bin.join("buzz-dev-mcp").to_str().unwrap(), + "--heartbeat-interval", + "10", + "--heartbeat-prompt", + "Run the fixture shell", + "--no-memory", + "--no-presence", + "--no-base-prompt", + ]) + .current_dir(dir) + .stdin(Stdio::null()) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .process_group(0) + .spawn() + .unwrap(); + OwnedChild(child) +} +fn pids(dir: &Path, root: u32) -> Vec { + let shell = wait_file(&dir.join("shell.pid")); + let grandchild = wait_file(&dir.join("grandchild.pid")); + let mcp = wait_file(&dir.join("mcp.pid")); + let agent = parent(mcp); + assert_eq!(parent(agent), root); + assert_eq!(parent(shell), mcp); + assert_eq!(parent(grandchild), shell); + let pids = vec![root, agent, mcp, shell, grandchild]; + assert!(pids.iter().all(|pid| running(*pid))); + let joined = pids + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + let tree = Command::new("/bin/ps") + .args(["-o", "pid=,ppid=,pgid=,comm=", "-p", &joined]) + .output() + .unwrap(); + assert!(tree.status.success()); + fs::write(dir.join("process-tree.txt"), &tree.stdout).unwrap(); + let text = String::from_utf8(tree.stdout).unwrap(); + eprintln!("{text}"); + for line in text.lines() { + let fields = line.split_whitespace().collect::>(); + let pid: u32 = fields[0].parse().unwrap(); + let pgid: u32 = fields[2].parse().unwrap(); + assert_eq!(pgid, if pid == grandchild { shell } else { pid }); + } + pids +} +fn assert_gone(pids: &[u32]) { + let deadline = Instant::now() + Duration::from_secs(3); + while pids.iter().any(|pid| running(*pid)) { + assert!( + Instant::now() < deadline, + "surviving selected descendants: {pids:?}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +#[test] +#[ignore = "requires current buzz-acp/agent/dev-mcp binaries and Node; see docs/host-execution.md"] +fn selected_generation_process_chain() { + let bin = std::path::PathBuf::from( + std::env::var_os("BUZZ_STOP_CHAIN_BIN_DIR").expect("set BUZZ_STOP_CHAIN_BIN_DIR"), + ); + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let temp = tempfile::tempdir().unwrap(); + // Keep logs on request for externally inspectable evidence, never secrets. + let dir = std::env::var_os("BUZZ_STOP_CHAIN_ARTIFACTS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| temp.path().to_owned()); + fs::create_dir_all(&dir).unwrap(); + let ready = dir.join("fixture.port"); + let _ = fs::remove_file(&ready); + let _fixture = OwnedChild( + Command::new("node") + .arg(repo.join("scripts/fixtures/stop-owner-chain.mjs")) + .arg(&ready) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .unwrap(), + ); + let port = wait_file(&ready); + // Same agent identity, different relay placement. Never start two current + // generations in one production placement just to manufacture a peer. + let peer_ready = dir.join("peer-fixture.port"); + let _ = fs::remove_file(&peer_ready); + let _peer_fixture = OwnedChild( + Command::new("node") + .arg(repo.join("scripts/fixtures/stop-owner-chain.mjs")) + .arg(&peer_ready) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .unwrap(), + ); + let peer_port = wait_file(&peer_ready); + let selected_dir = dir.join("selected"); + let peer_dir = dir.join("peer"); + fs::create_dir(&selected_dir).unwrap(); + fs::create_dir(&peer_dir).unwrap(); + let selected_generation = "aa".repeat(16); + let peer_generation = "bb".repeat(16); + let mut selected = spawn_run(&bin, &selected_dir, port, &selected_generation); + let mut peer = spawn_run(&bin, &peer_dir, peer_port, &peer_generation); + let selected_pids = pids(&selected_dir, selected.0.id()); + let peer_pids = pids(&peer_dir, peer.0.id()); + eprintln!("selected root/agent/MCP/shell/grandchild={selected_pids:?}; peer={peer_pids:?}"); + assert!( + stop_selected_generation(&mut selected.0, &selected_generation, &peer_generation).is_err() + ); + assert!(selected_pids + .iter() + .chain(&peer_pids) + .all(|pid| running(*pid))); + let start = Instant::now(); + stop_selected_generation(&mut selected.0, &selected_generation, &selected_generation).unwrap(); + assert_gone(&selected_pids); + let key = nostr::Keys::parse(&"1".repeat(64)) + .unwrap() + .public_key() + .to_hex(); + let proof = super::stop_proof_path(&selected_dir.join("harness.log"), &selected_generation); + assert!( + super::verified_stop_proof( + &proof, + &key, + &format!("ws://127.0.0.1:{port}"), + &selected_generation + ), + "missing or invalid supported proof: {}", + proof.display() + ); + assert!(!super::verified_stop_proof( + &proof, + &key, + &format!("ws://127.0.0.1:{peer_port}"), + &selected_generation + )); + assert!(!super::verified_stop_proof( + &proof, + &key, + &format!("ws://127.0.0.1:{port}"), + &peer_generation + )); + assert!(peer_pids.iter().all(|pid| running(*pid))); + eprintln!( + "selected teardown {:?}; every peer process preserved", + start.elapsed() + ); + assert!( + stop_selected_generation(&mut selected.0, &selected_generation, &selected_generation) + .is_err() + ); + assert!(peer_pids.iter().all(|pid| running(*pid))); + stop_selected_generation(&mut peer.0, &peer_generation, &peer_generation).unwrap(); + assert_gone(&peer_pids); + for path in [selected_dir, peer_dir] { + let log = fs::read_to_string(path.join("harness.log")).unwrap(); + assert!( + log.contains("agent connection closed and child reaped"), + "{log}" + ); + + assert!(!log.contains("teardown unconfirmed"), "{log}"); + assert!(!log.contains("killpg MCP"), "{log}"); + } +} diff --git a/desktop/src-tauri/src/managed_agents/host_location.rs b/desktop/src-tauri/src/managed_agents/host_location.rs new file mode 100644 index 00000000000..d8023b270a2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/host_location.rs @@ -0,0 +1,17 @@ +//! Stamp the actual local launcher, never saved provider routing or ambient env. +pub(super) fn apply( + command: &mut std::process::Command, + owner: Option<&str>, +) -> Result<(), String> { + command + .env_remove("BUZZ_ACP_HOST_PUBKEY") + .env_remove("BUZZ_ACP_HOST_LABEL"); + let Some(owner) = owner else { + return Ok(()); + }; + let location = crate::commands::local_launch_location(owner)?; + command + .env("BUZZ_ACP_HOST_PUBKEY", location.host) + .env("BUZZ_ACP_HOST_LABEL", location.label); + Ok(()) +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c005e8858b7..207a6c4da1a 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -16,6 +16,8 @@ mod definition_validation; mod discovery; pub(crate) mod effective_config; mod env_vars; +mod execution; +mod execution_ledger; pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; @@ -34,6 +36,9 @@ mod restore; pub mod retention; mod runtime; mod runtime_commands; +pub(crate) use execution::{ + execute_host_operation, execution_agent_owner, local_execution_config, start_after_exact_stop, +}; mod runtime_types; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; @@ -124,3 +129,5 @@ pub fn default_agent_workdir() -> Option { fn is_real_dir(path: &std::path::Path) -> bool { path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false) } + +mod host_location; diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index afaaa2b4eb3..d7c6180dc42 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -73,6 +73,8 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // for same-session sweep decisions. "BUZZ_MANAGED_AGENT", "BUZZ_MANAGED_AGENT_START_NONCE", + "BUZZ_ACP_HOST_PUBKEY", + "BUZZ_ACP_HOST_LABEL", ]; pub(crate) fn is_reserved_env_key(key: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index a225f492d33..e30206943aa 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -390,6 +390,7 @@ pub async fn restore_managed_agents_on_launch( pid: process.child.id(), desktop_instance_id: super::current_instance_id(app), started_at: now.clone(), + run_id: Some(process.start_nonce.clone()), }; if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { let _ = super::terminate_process(process.child.id()); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b219..73c4e4515c7 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -29,7 +29,7 @@ pub(crate) use metadata::{ mod stop; pub(crate) use stop::managed_agent_runtime_keys; -pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; +pub use stop::stop_managed_agent_process; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; @@ -42,7 +42,8 @@ use process::{ }; 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, + terminate_exact_owned_group, terminate_process, terminate_untracked_pair_runtime, + valid_agent_runtime_receipt, }; mod orphan_sweep; @@ -68,37 +69,14 @@ mod lifecycle; #[cfg(test)] use lifecycle::kill_stale_tracked_processes_with; pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; +mod spawn_entry; +use spawn_entry::child_rust_log_filter; +pub use spawn_entry::spawn_agent_child; mod spawn_key; // production spawn-key derivation + its regressions pub(crate) use spawn_key::bound_runtime_key; -/// Classify an agent's persona against the live catalog for the Agents-menu -/// drift indicator. Returns `(out_of_date, orphaned)`. -/// -/// Drift basis is the RECORD's `persona_source_version`, never the engram: -/// - persona_id set + persona present: out_of_date when the snapshot hash -/// differs from the persona's current content hash. -/// - persona_id set + persona gone: orphaned (no current hash to respawn into, -/// so never out_of_date — we must not tell the user to respawn into nothing). -/// - no persona_id: neither — a hand-built agent has no persona to drift from. -fn persona_drift_state( - record: &ManagedAgentRecord, - personas: &[crate::managed_agents::types::AgentDefinition], -) -> (bool, bool) { - let Some(persona_id) = record.persona_id.as_deref() else { - return (false, false); - }; - let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { - return (false, true); - }; - let current = crate::managed_agents::persona_events::persona_content_hash( - &crate::managed_agents::persona_events::persona_event_content(persona), - ); - let out_of_date = record - .persona_source_version - .as_deref() - .is_some_and(|pinned| pinned != current); - (out_of_date, false) -} +mod persona_drift; +use persona_drift::persona_drift_state; /// Resolve the runtime-pair key this record maps to for the active /// workspace: always the active workspace relay (the legacy per-record relay @@ -299,6 +277,8 @@ pub fn build_managed_agent_summary( .to_string(); Ok(ManagedAgentSummary { + selected_run_id: pair_runtime.map(|runtime| runtime.start_nonce.clone()), + selected_relay_url: pair_key.as_ref().map(|key| key.relay_url.clone()), pubkey: record.pubkey.clone(), name: record.name.clone(), persona_id: record.persona_id.clone(), @@ -397,18 +377,13 @@ pub(crate) fn configure_runtime_cli( } } -/// 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. -/// -/// `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( +pub(super) fn spawn_agent_child_for_run( app: &AppHandle, record: &ManagedAgentRecord, relay_url: &str, lazy: bool, owner_hex: Option<&str>, + generation: Option<(&str, &str)>, ) -> Result { if let Some(error) = spawn_key_refusal(record) { return Err(error); @@ -457,6 +432,13 @@ pub fn spawn_agent_child( crate::managed_agents::user_facing_harness_error(&e) ) })?; + if let Some((_, expected_revision)) = generation { + if super::execution::config_revision(record, &personas, &global, &teams, &descriptor)? + != expected_revision + { + return Err("destination configuration changed before spawn".into()); + } + } let effective_command = &descriptor.command; let agent_args = &descriptor.args; @@ -810,12 +792,9 @@ pub fn spawn_agent_child( command.env(key, value); } - // B5: carry persisted effort; harness resolves thought_level configId at first session. - // Written AFTER descriptor.env so the canonical persisted value wins over any - // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern - // (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is - // None there is no canonical value to assert, so env passthrough stands — user env - // legitimately seeds startup effort in that case. + // B5: carry persisted effort after user env; the harness resolves thought_level + // at first session. Canonical persisted effort wins, like ANTHROPIC_MODEL below. + // With no persisted effort, preserve user env to seed startup configuration. apply_effort_env(&mut command, record.effort_level.as_deref()); // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. @@ -826,6 +805,7 @@ pub fn spawn_agent_child( apply_claude_model_env(&mut command, effective_model.as_deref()); } configure_runtime_cli(&mut command, runtime_meta); + super::host_location::apply(&mut command, owner_hex)?; // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible // transport at spawn time and scrub any unrelated ambient OpenAI key. @@ -842,11 +822,14 @@ pub fn spawn_agent_child( } // Stamp desktop ownership and an unpredictable harness-generation identity. - let start_nonce = uuid::Uuid::new_v4().simple().to_string(); + let start_nonce = spawn_entry::launcher_generation(generation)?; command .env("BUZZ_MANAGED_AGENT", current_instance_id(app)) - .env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce); - + .env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce) + .env( + "BUZZ_STOP_RECEIPT_PATH", + super::execution::stop_proof_path(&log_path, &start_nonce), + ); // Stamp the effective spawn config from the values that populated the // `Command` above, BEFORE spawning. Re-resolving after `spawn()` would let // a persona/harness/global edit landing in between stamp the NEW config @@ -928,14 +911,6 @@ pub fn spawn_agent_child( }) } -fn child_rust_log_filter() -> String { - match std::env::var("RUST_LOG") { - Ok(existing) if existing.contains("buzz_acp") => existing, - Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"), - _ => "buzz_acp=info".to_string(), - } -} - /// Spawn (or adopt) the runtime pair for `record` on the caller's bound /// workspace relay. `workspace_relay` can only be produced by /// `bind_expected_relay_scope`, so this spawn consumes — by construction — the @@ -974,6 +949,7 @@ pub fn start_managed_agent_process( pid: process.child.id(), desktop_instance_id: current_instance_id(app), started_at: now.clone(), + run_id: Some(process.start_nonce.clone()), }; if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { let _ = terminate_process(process.child.id()); diff --git a/desktop/src-tauri/src/managed_agents/runtime/persona_drift.rs b/desktop/src-tauri/src/managed_agents/runtime/persona_drift.rs new file mode 100644 index 00000000000..2ed4fba8a5a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/persona_drift.rs @@ -0,0 +1,30 @@ +use super::ManagedAgentRecord; + +/// Classify an agent's persona against the live catalog for the Agents-menu +/// drift indicator. Returns `(out_of_date, orphaned)`. +/// +/// Drift basis is the RECORD's `persona_source_version`, never the engram: +/// - persona_id set + persona present: out_of_date when the snapshot hash +/// differs from the persona's current content hash. +/// - persona_id set + persona gone: orphaned (no current hash to respawn into, +/// so never out_of_date — we must not tell the user to respawn into nothing). +/// - no persona_id: neither — a hand-built agent has no persona to drift from. +pub(super) fn persona_drift_state( + record: &ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], +) -> (bool, bool) { + let Some(persona_id) = record.persona_id.as_deref() else { + return (false, false); + }; + let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { + return (false, true); + }; + let current = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(persona), + ); + let out_of_date = record + .persona_source_version + .as_deref() + .is_some_and(|pinned| pinned != current); + (out_of_date, false) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs index 26aa26f0747..e9a00536c9c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/process.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs @@ -241,12 +241,24 @@ fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result )) } +#[cfg(unix)] +fn signal_owner(pid: u32) -> Result<(), String> { + let status = std::process::Command::new("/bin/kill") + .args(["-TERM", &pid.to_string()]) + .status() + .map_err(|error| format!("cannot signal selected owner: {error}"))?; + if !status.success() { + return Err("cannot signal selected owner".into()); + } + Ok(()) +} + #[cfg(unix)] pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { - // Try graceful shutdown first (SIGTERM to the group). + // Legacy PID-only best-effort stop. Generation-fenced execution uses + // terminate_exact_owned_group below, retaining the child through cleanup. signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?; - // Wait up to 1s for graceful exit. for _ in 0..10 { if !process_is_running(pid) { return Ok(()); @@ -467,3 +479,136 @@ pub(crate) fn terminate_untracked_pair_runtime( super::super::remove_agent_runtime_receipt_path, ) } + +/// Teardown observation for generation-fenced operations. Unlike the legacy +/// helper, signal only the retained owner and allow nested stdio teardown before +/// escalation. Reap the root and observe its group disappearing. Detached groups +/// are outside this observation boundary; this is NOT a teardown certificate. +#[cfg(unix)] +pub(crate) fn terminate_exact_owned_group(child: &mut std::process::Child) -> Result<(), String> { + terminate_exact_owned_group_with_grace(child, std::time::Duration::from_secs(90)) +} + +#[cfg(unix)] +fn terminate_exact_owned_group_with_grace( + child: &mut std::process::Child, + grace: std::time::Duration, +) -> Result<(), String> { + if child + .try_wait() + .map_err(|_| "cannot inspect selected run")? + .is_some() + { + // Already reaped roots may have recycled PIDs. Never signal by a stale + // PID after restart/retry; lack of containment evidence remains unknown. + return Err("selected root already exited; group teardown is unconfirmed".into()); + } + let pid = child.id(); + signal_owner(pid)?; + let deadline = std::time::Instant::now() + grace; + loop { + if child + .try_wait() + .map_err(|_| "cannot wait for selected run")? + .is_some() + { + break; + } + if std::time::Instant::now() >= deadline { + // The root is still owned and unreaped. Escalation cannot certify + // separately grouped descendants, so leave the journal uncertain. + signal_process_group_or_leader(pid, libc::SIGKILL, "kill timed out run")?; + child.wait().map_err(|_| "failed to reap selected run")?; + return Err("selected owner timed out; descendant teardown unconfirmed".into()); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + // Do not signal a reaped PID (it can already have been recycled). The + // cooperative owner is responsible for draining its remaining groups. + for _ in 0..20 { + let output = std::process::Command::new("/bin/ps") + .args(["-axo", "pgid="]) + .output() + .map_err(|_| "cannot observe process groups")?; + if !output.status.success() { + return Err("cannot observe process groups".into()); + } + let text = + std::str::from_utf8(&output.stdout).map_err(|_| "invalid process group observation")?; + let groups = text + .split_whitespace() + .map(str::parse::) + .collect::, _>>() + .map_err(|_| "invalid process group observation")?; + if !groups.contains(&pid) { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err("selected run group teardown is unconfirmed".into()) +} + +#[cfg(not(unix))] +pub(crate) fn terminate_exact_owned_group(_child: &mut std::process::Child) -> Result<(), String> { + Err("exact run teardown observation is not supported on this platform".into()) +} + +#[cfg(all(test, unix))] +mod exact_run_tests { + use super::*; + use std::os::unix::process::CommandExt; + + struct ChildGuard(std::process::Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + fn child() -> ChildGuard { + ChildGuard( + std::process::Command::new("/bin/sleep") + .arg("60") + .process_group(0) + .spawn() + .unwrap(), + ) + } + #[test] + fn exact_group_teardown_reaps_root_and_leaves_peer_alive() { + let mut selected = child(); + let mut peer = child(); + terminate_exact_owned_group(&mut selected.0).unwrap(); + assert!(selected.0.try_wait().unwrap().is_some()); + assert!(peer.0.try_wait().unwrap().is_none()); + // Retry after the root was reaped cannot signal a potentially reused PID. + assert!(terminate_exact_owned_group(&mut selected.0).is_err()); + assert!(peer.0.try_wait().unwrap().is_none()); + } + #[test] + fn hung_owner_escalation_is_uncertain_and_preserves_peer() { + use std::io::BufRead; + let mut selected = ChildGuard( + std::process::Command::new("/bin/sh") + .args(["-c", "trap '' TERM; echo ready; exec sleep 600"]) + .stdout(std::process::Stdio::piped()) + .process_group(0) + .spawn() + .unwrap(), + ); + let mut line = String::new(); + std::io::BufReader::new(selected.0.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + assert_eq!(line.trim(), "ready"); + let mut peer = child(); + assert!(terminate_exact_owned_group_with_grace( + &mut selected.0, + std::time::Duration::from_millis(30) + ) + .unwrap_err() + .contains("unconfirmed")); + assert!(selected.0.try_wait().unwrap().is_some()); + assert!(peer.0.try_wait().unwrap().is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/spawn_entry.rs b/desktop/src-tauri/src/managed_agents/runtime/spawn_entry.rs new file mode 100644 index 00000000000..77ce042b793 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/spawn_entry.rs @@ -0,0 +1,41 @@ +use crate::managed_agents::ManagedAgentRecord; +use tauri::AppHandle; + +/// 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. +/// +/// `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( + app: &AppHandle, + record: &ManagedAgentRecord, + relay_url: &str, + lazy: bool, + owner_hex: Option<&str>, +) -> Result { + // Durable Stop fences config-driven and legacy starts too. Hold the OS lock + // through spawn so a concurrent executor cannot persist Stop between check + // and process creation. + let _fence = + crate::managed_agents::execution::legacy_spawn_guard(app, record, relay_url, owner_hex)?; + super::spawn_agent_child_for_run(app, record, relay_url, lazy, owner_hex, None) +} + +pub(super) fn child_rust_log_filter() -> String { + match std::env::var("RUST_LOG") { + Ok(existing) if existing.contains("buzz_acp") => existing, + Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"), + _ => "buzz_acp=info".to_string(), + } +} + +pub(super) fn launcher_generation(generation: Option<(&str, &str)>) -> Result { + let run = generation + .map(|(run, _)| run.to_owned()) + .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); + if !buzz_core_pkg::host_execution::hex_id(&run, 32) { + return Err("invalid launcher generation".into()); + } + Ok(run) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 7b8ded7926d..bb7af4dae31 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -111,48 +111,6 @@ fn stop_legacy_scalar_pid( Ok(()) } -/// Stop the runtime pair this record resolves to for the active workspace -/// (explicit relay pin, else the active workspace relay) — the pair-scoped -/// counterpart of [`stop_managed_agent_process`], which drains every pair. -/// -/// Community-scoped surfaces (profile panel, Agents tab, auto-restart) stop -/// through here so stopping an agent in one community never tears down its -/// pairs in other communities. Clears the matching agent session cache -/// (pair-scoped when a pair key resolves). When no pair is tracked for this -/// workspace, only legacy scalar-PID cleanup runs. -pub fn stop_managed_agent_workspace_pair( - app: &AppHandle, - record: &mut ManagedAgentRecord, - runtimes: &mut HashMap, -) -> Result<(), String> { - use tauri::Manager; - let state = app.state::(); - match super::workspace_pair_key(app, record) { - Some(pair_key) if runtimes.contains_key(&pair_key) => { - stop_managed_agent_pair(app, record, runtimes, &pair_key)?; - state.clear_agent_session_cache(&pair_key); - super::super::remove_agent_pid_file(app, &record.pubkey); - let now = now_iso(); - record.runtime_pid = None; - record.updated_at = now.clone(); - record.last_stopped_at = Some(now); - record.last_error = None; - record.last_error_code = None; - } - Some(pair_key) => { - // No tracked pair here — a pubkey-wide cache clear would disturb - // live pairs in other communities, so stay pair-scoped. - stop_legacy_scalar_pid(app, record)?; - state.clear_agent_session_cache(&pair_key); - } - None => { - stop_legacy_scalar_pid(app, record)?; - state.clear_agent_session_caches(&record.pubkey); - } - } - Ok(()) -} - pub fn stop_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index ec78cc14efa..f531632cbdf 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -94,3 +94,15 @@ pub(super) fn fixture( effort_level: None, } } + +pub(super) fn receipt_fixture( + key: crate::managed_agents::ManagedAgentRuntimeKey, +) -> crate::managed_agents::ManagedAgentRuntimeReceipt { + crate::managed_agents::ManagedAgentRuntimeReceipt { + key, + pid: std::process::id(), + desktop_instance_id: "test-instance".into(), + started_at: "now".into(), + run_id: None, + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 24fad1461c5..99a6a8221bb 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,3 +1,4 @@ +use super::test_fixtures::receipt_fixture; use crate::managed_agents::known_acp_runtime; #[path = "cli_tests.rs"] @@ -856,17 +857,6 @@ fn own_group_grandchild_detected_by_ancestor_walk() { // ── pair receipt validation tests ─────────────────────────────────────── -fn receipt_fixture( - key: crate::managed_agents::ManagedAgentRuntimeKey, -) -> crate::managed_agents::ManagedAgentRuntimeReceipt { - crate::managed_agents::ManagedAgentRuntimeReceipt { - key, - pid: std::process::id(), - desktop_instance_id: "test-instance".into(), - started_at: "now".into(), - } -} - #[test] fn receipt_validation_rejects_noncanonical_identity() { let mut receipt = receipt_fixture( diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 135224d01db..b48ed685176 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -3,13 +3,12 @@ use std::sync::atomic::Ordering; use tauri::{AppHandle, Emitter, Manager}; use super::{ - agent_readiness, append_log_marker, current_instance_id, find_managed_agent_mut, - load_global_agent_config, load_managed_agents, load_personas, managed_agent_runtime_log_path, - process_is_running, record_agent_command, resolve_effective_agent_env, save_managed_agents, - spawn_agent_child, terminate_process, terminate_untracked_pair_runtime, - write_agent_runtime_receipt, AgentReadiness, BackendKind, ManagedAgentPairRuntime, - ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, - ManagedAgentRuntimeStatus, + agent_readiness, current_instance_id, find_managed_agent_mut, load_global_agent_config, + load_managed_agents, load_personas, managed_agent_runtime_log_path, record_agent_command, + resolve_effective_agent_env, save_managed_agents, spawn_agent_child, terminate_process, + terminate_untracked_pair_runtime, write_agent_runtime_receipt, AgentReadiness, BackendKind, + ManagedAgentPairRuntime, ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, + ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, }; use crate::app_state::AppState; @@ -66,6 +65,7 @@ fn status_for_with( .map(|runtime| runtime.lifecycle.clone()) .unwrap_or(ManagedAgentRuntimeLifecycle::Stopped), pid: runtime.map(|runtime| runtime.child.id()), + run_id: runtime.map(|runtime| runtime.start_nonce.clone()), error: runtime.and_then(|runtime| runtime.error.clone()), log_path: managed_agent_runtime_log_path(app, key) .ok() @@ -238,6 +238,27 @@ pub fn start_managed_agent_runtime( relay_url: String, app: AppHandle, ) -> Result { + let owner = app + .state::() + .signing_keys()? + .public_key() + .to_hex(); + if super::execution::start_after_exact_stop(&app, &pubkey, &relay_url, &owner)? { + let state = app.state::(); + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("agent not found")?; + let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let status = status_for(&app, record, &key, runtimes.get(&key), None); + emit_status(&app, &status); + return Ok(status); + } start_managed_agent_runtime_pair_lazy(pubkey, relay_url, app) } @@ -295,6 +316,7 @@ fn start_pair( pid: process.child.id(), desktop_instance_id: current_instance_id(&app), started_at: now.clone(), + run_id: Some(process.start_nonce.clone()), }; if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { let _ = terminate_process(process.child.id()); @@ -318,65 +340,32 @@ fn start_pair( pub fn stop_managed_agent_runtime( pubkey: String, relay_url: String, + selected_run_id: Option, app: AppHandle, ) -> Result { - let state = app.state::(); - let _transition = state - .managed_agent_runtime_transition - .lock() - .map_err(|e| e.to_string())?; - let _store = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let record = find_managed_agent_mut(&mut records, &pubkey)?; + super::execution::stop_local_selected_run( + &app, + &pubkey, + &relay_url, + selected_run_id.as_deref(), + )?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("agent not found")?; let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; - let mut runtimes = state + let state = app.state::(); + let runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - if let Some(mut runtime) = runtimes.remove(&key) { - let stop_result = if process_is_running(runtime.child.id()) { - terminate_process(runtime.child.id()) - } else { - Ok(()) - } - .and_then(|()| runtime.child.wait().map_err(|e| e.to_string())); - match stop_result { - Ok(status) => { - record.last_exit_code = status.code(); - let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ==="); - } - Err(error) => { - // Keep failed teardown visible/manageable instead of - // orphaning it: the child stays tracked and the receipt - // stays on disk until a stop actually succeeds. - runtimes.insert(key, runtime); - return Err(error); - } - } - } else { - // No runtime is tracked at this key, but a valid prior-session - // receipt may still point at a live child (e.g. the crash-recovery - // window for a non-auto-start agent). Terminate that orphan before - // erasing its receipt — otherwise this "stop" leaves the harness - // running yet deletes the one artifact sweeps and - // terminate_untracked_pair_runtime use to find it, and a follow-up - // start would spawn a duplicate harness for the same pair. On - // failure the receipt stays on disk (terminate_untracked_pair_runtime - // only removes it after the child exits), mirroring the tracked - // path's keep-until-success invariant. - terminate_untracked_pair_runtime(&app, &key)?; + if runtimes.contains_key(&key) { + return Err( + "A successor is running; refresh status (the selected Stop did not stop it)".into(), + ); } - super::remove_agent_runtime_receipt(&app, &key); - state.clear_agent_session_cache(&key); - record.runtime_pid = None; - record.updated_at = crate::util::now_iso(); - record.last_stopped_at = Some(record.updated_at.clone()); let status = status_for(&app, record, &key, None, None); - drop(runtimes); - save_managed_agents(&app, &records)?; emit_status(&app, &status); Ok(status) } @@ -385,10 +374,16 @@ pub fn stop_managed_agent_runtime( pub fn restart_managed_agent_runtime( pubkey: String, relay_url: String, + selected_run_id: Option, app: AppHandle, ) -> Result { - stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?; - start_pair(pubkey, relay_url, true, None, app) + stop_managed_agent_runtime( + pubkey.clone(), + relay_url.clone(), + selected_run_id, + app.clone(), + )?; + start_managed_agent_runtime(pubkey, relay_url, app) } /// Probe whether this agent can operate on `requested_relay_url`. @@ -446,6 +441,7 @@ fn unkeyable_failed_status( local_setup: matches!(agent_readiness(&effective), AgentReadiness::Ready), lifecycle: ManagedAgentRuntimeLifecycle::Failed, pid: None, + run_id: None, error: Some(error), log_path: None, } diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs index 4862cedbae3..15067ec86de 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_types.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -90,6 +90,8 @@ pub struct ManagedAgentRuntimeStatus { pub local_setup: bool, pub lifecycle: ManagedAgentRuntimeLifecycle, pub pid: Option, + /// Launcher generation, also the public run-presence ID. + pub run_id: Option, pub error: Option, pub log_path: Option, } @@ -117,4 +119,7 @@ pub struct ManagedAgentRuntimeReceipt { pub pid: u32, pub desktop_instance_id: String, pub started_at: String, + /// Absent on legacy receipts; never infer an exact-run stop from their PID. + #[serde(default)] + pub run_id: Option, } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 7d4b43f01d8..658f48e44f2 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -492,6 +492,10 @@ pub struct ManagedAgentProcess { #[derive(Debug, Clone, Serialize)] pub struct ManagedAgentSummary { + /// Selected active workspace generation; absent for legacy/untracked runtimes. + pub selected_run_id: Option, + /// Community corresponding to selected_run_id, not a stored legacy pin. + pub selected_relay_url: Option, pub pubkey: String, pub name: String, pub persona_id: Option, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 5299eb4ecca..1fa7d015d72 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -717,6 +717,8 @@ fn summary_fixture( restart_diff: Vec, ) -> super::ManagedAgentSummary { super::ManagedAgentSummary { + selected_run_id: None, + selected_relay_url: None, pubkey: "aa".repeat(32), name: "test".into(), persona_id: None, diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f408ef2afda..c2fbb64f1d5 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -100,7 +100,7 @@ pub fn relay_http_base_url(relay_url: &str) -> String { mod scope; pub use scope::{ assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope, - bind_expected_signer, ScopedWorkspaceRelay, + bind_expected_signer, ScopedWorkspaceRelay, ScopedWorkspaceSigner, }; pub fn relay_api_base_url() -> String { @@ -350,6 +350,9 @@ pub async fn relay_error_message(response: reqwest::Response) -> String { format!("relay returned {status}") } +mod private_host; +pub(crate) use private_host::{query_private_host_at_with_keys, PRIVATE_HOST_REQUEST_TIMEOUT}; + // ── HTTP bridge: POST /query ──────────────────────────────────────────────── /// Execute a one-shot query via the relay's HTTP bridge (`POST /query`). diff --git a/desktop/src-tauri/src/relay/private_host.rs b/desktop/src-tauri/src/relay/private_host.rs new file mode 100644 index 00000000000..e69891bdd21 --- /dev/null +++ b/desktop/src-tauri/src/relay/private_host.rs @@ -0,0 +1,41 @@ +//! Selected-owner private host inventory, authorization and execution history. +//! These POST bodies must never follow redirects, even when sensitive HTTP +//! headers would be stripped: the filters themselves disclose private metadata. +use super::*; + +/// Per-request deadline includes response-body consumption, not rate-limit wait. +/// Private host reads/writes deliberately use a shorter budget than WS history. +pub(crate) const PRIVATE_HOST_REQUEST_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(15); + +/// Read private host records with the current unlocked owner's authority, using +/// the same no-redirect client and deadline as host execution publication. +pub(crate) async fn query_private_host_at_with_keys( + state: &AppState, + api_base_url: &str, + filters: &[serde_json::Value], + keys: &Keys, + auth_tag: Option<&str>, +) -> Result, String> { + crate::relay_admission::wait_for_rate_limit().await; + assert_expected_signer( + Some(&keys.public_key().to_hex()), + &state.signing_keys()?.public_key().to_hex(), + )?; + let url = format!("{}/query", api_base_url.trim_end_matches('/')); + let body = + serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; + let auth = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body)?; + send_query_request( + &state.media_fetch_client, + &url, + &auth, + auth_tag, + body, + PRIVATE_HOST_REQUEST_TIMEOUT, + ) + .await +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/relay/private_host/tests.rs b/desktop/src-tauri/src/relay/private_host/tests.rs new file mode 100644 index 00000000000..0601671631d --- /dev/null +++ b/desktop/src-tauri/src/relay/private_host/tests.rs @@ -0,0 +1,187 @@ +use super::*; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +async fn read_request(stream: &mut TcpStream) -> (String, Vec) { + let mut bytes = Vec::new(); + loop { + let mut buf = [0; 4096]; + let n = stream.read(&mut buf).await.unwrap(); + assert_ne!(n, 0); + bytes.extend_from_slice(&buf[..n]); + if let Some(end) = bytes.windows(4).position(|b| b == b"\r\n\r\n") { + let headers = String::from_utf8(bytes[..end].to_vec()).unwrap(); + let len: usize = headers + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().unwrap()) + }) + .unwrap(); + if bytes.len() >= end + 4 + len { + return (headers, bytes[end + 4..end + 4 + len].to_vec()); + } + } + } +} + +fn state_and_keys() -> (AppState, Keys) { + let state = crate::app_state::build_app_state(); + let keys = Keys::generate(); + *state.keys.lock().unwrap() = keys.clone(); + (state, keys) +} + +#[tokio::test] +async fn private_host_redirects_never_contact_target_or_disclose_filters() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + for status in [307, 308] { + let origin = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", origin.local_addr().unwrap()); + let target_url = format!("http://{}/private-leak", target.local_addr().unwrap()); + let (state, keys) = state_and_keys(); + let filters = + [serde_json::json!({"kinds":[50002],"#p":[keys.public_key().to_hex()],"limit":1000})]; + let expected_body = serde_json::to_vec(&filters).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = origin.accept().await.unwrap(); + let (_, body) = read_request(&mut stream).await; + assert_eq!(body, expected_body); + stream.write_all(format!("HTTP/1.1 {status} Redirect\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap(); + }); + let result = tokio::time::timeout( + Duration::from_secs(3), + query_private_host_at_with_keys(&state, &base, &filters, &keys, None), + ) + .await + .unwrap(); + assert!(result.unwrap_err().contains(&status.to_string())); + server.await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(100), target.accept()) + .await + .is_err(), + "redirect target must receive no request, including no filter body" + ); + } +} + +#[tokio::test] +async fn private_host_configured_auth_is_bound_to_exact_url_and_body() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + let origin = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", origin.local_addr().unwrap()); + let url = format!("{base}/query"); + let (state, keys) = state_and_keys(); + let signer = keys.public_key(); + let server = tokio::spawn(async move { + let (mut stream, _) = origin.accept().await.unwrap(); + let (headers, body) = read_request(&mut stream).await; + let header = |name: &str| { + headers + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name).then(|| value.trim()) + }) + .unwrap() + }; + assert_eq!(header("x-auth-tag"), "synthetic-configured-auth"); + let encoded = header("authorization").strip_prefix("Nostr ").unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + let auth = nostr::Event::from_json(bytes).unwrap(); + buzz_core_pkg::verify_event(&auth).unwrap(); + assert_eq!(auth.pubkey, signer); + assert!(auth.tags.iter().any(|t| t.as_slice() == ["u", &url])); + assert!(auth.tags.iter().any(|t| t.as_slice() == ["method", "POST"])); + assert!(auth + .tags + .iter() + .any(|t| t.as_slice() == ["payload", &hex::encode(Sha256::digest(&body))])); + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]").await.unwrap(); + }); + assert!(query_private_host_at_with_keys( + &state, + &base, + &[serde_json::json!({"kinds":[50000],"#p":[signer.to_hex()]})], + &keys, + Some("synthetic-configured-auth") + ) + .await + .unwrap() + .is_empty()); + server.await.unwrap(); +} + +#[tokio::test] +async fn private_host_deadline_covers_stalled_headers_and_body() { + // Exercise the actual 15s policy (no test-only shorter timeout). + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + assert_eq!(PRIVATE_HOST_REQUEST_TIMEOUT, Duration::from_secs(15)); + for send_headers in [false, true] { + let origin = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", origin.local_addr().unwrap()); + let (state, keys) = state_and_keys(); + let server = tokio::spawn(async move { + let (mut stream, _) = origin.accept().await.unwrap(); + read_request(&mut stream).await; + if send_headers { + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 50\r\n\r\n[").await.unwrap(); + } + std::future::pending::<()>().await; + }); + let start = std::time::Instant::now(); + let result = tokio::time::timeout( + Duration::from_secs(20), + query_private_host_at_with_keys( + &state, + &base, + &[serde_json::json!({"kinds":[50002]})], + &keys, + None, + ), + ) + .await + .unwrap(); + assert_eq!(result.unwrap_err(), "relay unreachable: request timed out"); + assert!(start.elapsed() >= Duration::from_secs(14)); + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +async fn private_host_locked_or_changed_identity_has_no_egress() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + let origin = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", origin.local_addr().unwrap()); + let (state, keys) = state_and_keys(); + for gate in [&state.keyring_locked, &state.identity_lost] { + gate.store(true, std::sync::atomic::Ordering::Release); + assert!( + query_private_host_at_with_keys(&state, &base, &[], &keys, None) + .await + .is_err() + ); + gate.store(false, std::sync::atomic::Ordering::Release); + } + assert!( + query_private_host_at_with_keys(&state, &base, &[], &Keys::generate(), None) + .await + .is_err() + ); + assert!( + tokio::time::timeout(Duration::from_millis(100), origin.accept()) + .await + .is_err() + ); +} diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index 43854761b50..fffd6c4c993 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -20,6 +20,8 @@ //! adding an env tier here would duplicate that precedence and create a //! divergent-behavior trap. +mod host_key; + use std::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; @@ -922,385 +924,4 @@ impl SecretStore { } #[cfg(all(test, feature = "system-keyring"))] -mod tests { - use super::*; - - // Test-only constructor: pre-seed the cache without touching the OS keychain. - impl SecretStore { - fn with_cache(service: &str, cache: Option>) -> Self { - SecretStore { - service: service.to_string(), - cache: Mutex::new(cache), - } - } - } - - #[test] - fn probe_returns_present_when_key_in_cache() { - let mut map = HashMap::new(); - map.insert("identity".to_string(), "nsec1test".to_string()); - let store = SecretStore::with_cache("buzz-test-cache-hit", Some(map)); - // Cache is warm and contains "identity" — probe must return Present - // without touching the keychain. - assert_eq!(store.probe("identity"), KeyringProbe::Present); - } - - #[test] - fn load_returns_value_when_key_in_cache() { - let mut map = HashMap::new(); - map.insert("identity".to_string(), "nsec1test".to_string()); - let store = SecretStore::with_cache("buzz-test-load-cache-hit", Some(map)); - // Cache is warm and contains "identity" — load must return the value - // without touching the keychain. - assert_eq!( - store.load("identity").unwrap(), - Some("nsec1test".to_string()) - ); - } - - // ── Cross-process race tests (require real OS keychain) ──────────────── - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn test_stale_warm_cache_add_observes_prior_write() { - // Simulates the cross-process race that stranded Will's agent keys. - // - // Setup: two SecretStore instances for the same service (= two - // "processes" with separate caches). Process A warms its cache to - // {k1}. Process B then writes {k1, k2}. Without the fix, A's next - // mutate_blob would build from its stale {k1} cache and write - // {k1, k3}, silently dropping k2. With the fix, A always re-reads - // from the keychain inside the lock, so the result is {k1, k2, k3}. - let svc = "buzz-test-race-stale-cache"; - - // Clean state. - let setup = SecretStore::keyring(svc); - let _ = setup.delete("k1"); - let _ = setup.delete("k2"); - let _ = setup.delete("k3"); - - // Process A: write k1, warming its cache. - let store_a = SecretStore::keyring(svc); - store_a.store("k1", "v1").unwrap(); - - // Process B: write k2 (separate instance = separate cache). - let store_b = SecretStore::keyring(svc); - store_b.store("k2", "v2").unwrap(); - - // Process A: write k3. With the fix, A re-reads inside the lock and - // sees {k1, k2} before appending k3 — result must be {k1, k2, k3}. - store_a.store("k3", "v3").unwrap(); - - // Verify via a third reader (clean cache). - let reader = SecretStore::keyring(svc); - assert_eq!( - reader.load("k1").unwrap(), - Some("v1".to_string()), - "k1 must survive" - ); - assert_eq!( - reader.load("k2").unwrap(), - Some("v2".to_string()), - "k2 must not be dropped" - ); - assert_eq!( - reader.load("k3").unwrap(), - Some("v3".to_string()), - "k3 must be written" - ); - - // Cleanup. - let _ = reader.delete("k1"); - let _ = reader.delete("k2"); - let _ = reader.delete("k3"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn test_concurrent_adds_neither_key_dropped() { - // Two sequential stores from distinct instances (simulating two - // processes each adding one key) must both be durably visible. - let svc = "buzz-test-race-concurrent-add"; - - let setup = SecretStore::keyring(svc); - let _ = setup.delete("agent_a"); - let _ = setup.delete("agent_b"); - - let store1 = SecretStore::keyring(svc); - store1.store("agent_a", "nsec1aaa").unwrap(); - - let store2 = SecretStore::keyring(svc); - store2.store("agent_b", "nsec1bbb").unwrap(); - - let reader = SecretStore::keyring(svc); - assert_eq!( - reader.load("agent_a").unwrap(), - Some("nsec1aaa".to_string()), - "agent_a must not be dropped" - ); - assert_eq!( - reader.load("agent_b").unwrap(), - Some("nsec1bbb".to_string()), - "agent_b must not be dropped" - ); - - // Cleanup. - let _ = reader.delete("agent_a"); - let _ = reader.delete("agent_b"); - } - - #[test] - fn test_blob_lockfile_path_is_in_tmp_with_uid() { - // The lockfile must be at a deterministic per-user path under /tmp — - // invariant to $TMPDIR — so both a GUI-launched DMG (env-stripped by - // launchd) and a terminal-launched dev build resolve the same inode and - // achieve mutual exclusion. - let path = blob_lockfile_path("buzz-desktop"); - #[cfg(unix)] - { - let uid = unsafe { libc::getuid() }; - assert!( - path.starts_with("/tmp"), - "lockfile {path:?} must start with /tmp (not $TMPDIR)" - ); - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default(); - assert!( - name.contains(&uid.to_string()), - "lockfile {path:?} must contain uid {uid}" - ); - assert!( - name.contains("buzz-keychain"), - "lockfile name must contain 'buzz-keychain'" - ); - } - #[cfg(not(unix))] - { - assert!( - path.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.contains("buzz-keychain")), - "lockfile name must contain 'buzz-keychain'" - ); - } - } - - #[test] - fn test_blob_lock_acquire_and_release() { - // Verify the advisory lock can be acquired and released without errors. - // This exercises the real flock/mutex path on the current platform. - let guard = acquire_blob_lock("buzz-test-lock-smoke"); - assert!( - guard.is_ok(), - "advisory lock acquire must succeed: {:?}", - guard.err() - ); - // Drop the guard — lock is released. A second acquire must succeed. - drop(guard); - let guard2 = acquire_blob_lock("buzz-test-lock-smoke"); - assert!( - guard2.is_ok(), - "advisory lock re-acquire after release must succeed: {:?}", - guard2.err() - ); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn mutate_blob_does_not_advance_cache_on_write_failure() { - // Copy-on-write safety: if `write_blob_raw` fails (denied prompt, - // transient outage, ACL rejection), the cache must stay at the last - // known durable state. A subsequent `store()` for the same key/value - // must NOT be skipped as a no-op — the equality check must compare - // against the durable cache, not an unpersisted candidate. - // - // This is a real-keychain integration test. Run locally with: - // cargo test -p buzz-desktop -- --ignored mutate_blob_does_not_advance - // - // On a machine with a reachable keychain the `store()` call succeeds - // (result.is_ok()) and the write-failure branch is skipped — the test - // still passes. On a machine where the write is denied (e.g., user - // clicks Deny in the macOS prompt) result.is_err() and the assertions - // below verify the cache invariant. We verify that after an error: - // 1. The cache is not advanced (the previously cached key is intact). - // 2. The failed key is not present (the dirty candidate was discarded). - let mut map = HashMap::new(); - map.insert("existing".to_string(), "durable_val".to_string()); - let store = SecretStore::with_cache("buzz-test-cow-write-fail", Some(map)); - - // Attempt to add a new key — this calls write_blob_raw against the - // real keychain; with copy-on-write the cache must remain at {existing} - // if the write fails. - let result = store.store("new_key", "new_val"); - - if result.is_err() { - // Write failed (e.g., user denied the keychain prompt): confirm - // cache was not advanced — the existing key is still intact and - // the new key was never committed to the in-memory state. - assert_eq!( - store.load("existing").unwrap(), - Some("durable_val".to_string()), - "cache must remain at last durable state after write failure" - ); - // load("new_key") goes through the unchanged cache (no entry), - // then attempts migrate_legacy_key which also fails on a denied - // keychain, returning either Ok(None) or Err — either is correct - // since the key was never durably stored. - let after = store.load("new_key"); - assert!( - matches!(after, Ok(None) | Err(_)), - "a key whose write failed must not be visible via load: {after:?}" - ); - } - // If result.is_ok() the write succeeded — the cache-integrity invariant - // does not apply to the success path; no assertion needed here. - } - - #[test] - fn availability_error_discriminator() { - assert!(is_keyring_availability_error("dbus connection failed")); - assert!(is_keyring_availability_error( - "org.freedesktop.secrets not provided" - )); - assert!(is_keyring_availability_error("No Secret Service")); - assert!(is_keyring_availability_error( - "Platform secure storage failure" - )); - // A plain "not found" is per-entry, not an availability failure. - assert!(!is_keyring_availability_error("entry not found")); - } - - #[cfg(target_os = "macos")] - #[test] - fn dpk_error_discriminators() { - // errSecMissingEntitlement = -34018 signals unsigned dev build. - let e = SFError::from_code(-34018); - assert!(is_dpk_unavailable(&e)); - assert!(!is_not_found(&e)); - // errSecItemNotFound = -25300 is not a DPK-unavailable error. - let e = SFError::from_code(-25300); - assert!(is_not_found(&e)); - assert!(!is_dpk_unavailable(&e)); - } - - // Integration tests that exercise the real OS keychain. Skipped in CI - // (unsigned builds lack keychain entitlements); run locally with: - // cargo test -p buzz-desktop -- --ignored blob_ - // - // Each test uses a unique service name to avoid cross-test pollution. - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_stores_and_retrieves_multiple_keys() { - let store = SecretStore::keyring("buzz-test-blob-multi"); - store.store("key_a", "val_a").unwrap(); - store.store("key_b", "val_b").unwrap(); - assert_eq!(store.load("key_a").unwrap(), Some("val_a".to_string())); - assert_eq!(store.load("key_b").unwrap(), Some("val_b".to_string())); - assert_eq!(store.load("key_c").unwrap(), None); - // Cleanup. - let _ = store.delete("key_a"); - let _ = store.delete("key_b"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_probe_present_absent_unreachable() { - let store = SecretStore::keyring("buzz-test-blob-probe"); - // No blob yet — key absent, backend reachable. - assert_eq!(store.probe("identity"), KeyringProbe::ReachableButEmpty); - store.store("identity", "nsec1test").unwrap(); - // Key now present. - assert_eq!(store.probe("identity"), KeyringProbe::Present); - // Different key — blob exists but key absent. - assert_eq!(store.probe("other"), KeyringProbe::ReachableButEmpty); - // Cleanup. - let _ = store.delete("identity"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_delete_removes_key_not_others() { - let store = SecretStore::keyring("buzz-test-blob-delete"); - store.store("keep", "keep_val").unwrap(); - store.store("remove", "remove_val").unwrap(); - store.delete("remove").unwrap(); - assert_eq!(store.load("keep").unwrap(), Some("keep_val".to_string())); - assert_eq!(store.load("remove").unwrap(), None); - // Cleanup. - let _ = store.delete("keep"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_migration_from_per_key_entry() { - let svc = "buzz-test-blob-migration"; - let key = "identity"; - let value = "nsec1migrationtest"; - - // Seed a per-key entry (old format) — no blob exists. - let entry = keyring_entry(svc, key).unwrap(); - entry.set_password(value).unwrap(); - - // Fresh store — no blob in the keychain yet. - let store = SecretStore::keyring(svc); - - // probe should find the legacy key. - assert_eq!(store.probe(key), KeyringProbe::Present); - - // load should migrate it into the blob and return the value. - assert_eq!(store.load(key).unwrap(), Some(value.to_string())); - - // Old per-key entry should be cleaned up. - let entry = keyring_entry(svc, key).unwrap(); - assert!(matches!(entry.get_password(), Err(keyring::Error::NoEntry))); - - // Key is now in the blob — probe confirms. - let store2 = SecretStore::keyring(svc); - assert_eq!(store2.probe(key), KeyringProbe::Present); - assert_eq!(store2.load(key).unwrap(), Some(value.to_string())); - - // Cleanup. - let _ = store2.delete(key); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn delete_all_with_legacy_cleanup_removes_per_key_identity() { - let svc = "buzz-test-delete-all-legacy"; - let key = "identity"; - let value = "nsec1legacytest"; - - // Seed a legacy per-key entry (old format, pre-blob migration). - let entry = keyring_entry(svc, key).unwrap(); - entry.set_password(value).unwrap(); - - // Also seed a blob with a different key to exercise the full path. - let store = SecretStore::keyring(svc); - store.store("agent:abc123", "nsec1agent").unwrap(); - - // Legacy per-key identity should be discoverable via probe. - let store2 = SecretStore::keyring(svc); - assert_eq!(store2.probe(key), KeyringProbe::Present); - - // Wipe everything via the sign-out path. - store2.delete_all_with_legacy_cleanup().unwrap(); - - // Fresh store — neither the blob nor the per-key entry should remain. - let store3 = SecretStore::keyring(svc); - assert_eq!( - store3.probe(key), - KeyringProbe::ReachableButEmpty, - "per-key identity must not survive delete_all_with_legacy_cleanup" - ); - assert_eq!( - store3.load(key).unwrap(), - None, - "load must not resurrect the legacy per-key identity" - ); - // Agent key should also be gone. - assert_eq!(store3.load("agent:abc123").unwrap(), None); - } -} +mod tests; diff --git a/desktop/src-tauri/src/secret_store/host_key.rs b/desktop/src-tauri/src/secret_store/host_key.rs new file mode 100644 index 00000000000..c171bda0169 --- /dev/null +++ b/desktop/src-tauri/src/secret_store/host_key.rs @@ -0,0 +1,20 @@ +//! Host key creation uses the same cross-process keychain transaction as agents. +impl super::SecretStore { + /// Atomically keep an existing secret or insert a candidate. Never overwrite. + pub(crate) fn host_key(&self, name: &str) -> Result { + #[cfg(feature = "system-keyring")] + { + let candidate = nostr::Keys::generate().secret_key().to_secret_hex(); + let mut result = String::new(); + self.mutate_blob(|blob| { + result = blob.entry(name.to_owned()).or_insert(candidate).clone(); + })?; + Ok(result) + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = name; + Err("Host registration requires secure key storage in this build".into()) + } + } +} diff --git a/desktop/src-tauri/src/secret_store/tests.rs b/desktop/src-tauri/src/secret_store/tests.rs new file mode 100644 index 00000000000..b498fb5c5df --- /dev/null +++ b/desktop/src-tauri/src/secret_store/tests.rs @@ -0,0 +1,380 @@ +use super::*; + +// Test-only constructor: pre-seed the cache without touching the OS keychain. +impl SecretStore { + fn with_cache(service: &str, cache: Option>) -> Self { + SecretStore { + service: service.to_string(), + cache: Mutex::new(cache), + } + } +} + +#[test] +fn probe_returns_present_when_key_in_cache() { + let mut map = HashMap::new(); + map.insert("identity".to_string(), "nsec1test".to_string()); + let store = SecretStore::with_cache("buzz-test-cache-hit", Some(map)); + // Cache is warm and contains "identity" — probe must return Present + // without touching the keychain. + assert_eq!(store.probe("identity"), KeyringProbe::Present); +} + +#[test] +fn load_returns_value_when_key_in_cache() { + let mut map = HashMap::new(); + map.insert("identity".to_string(), "nsec1test".to_string()); + let store = SecretStore::with_cache("buzz-test-load-cache-hit", Some(map)); + // Cache is warm and contains "identity" — load must return the value + // without touching the keychain. + assert_eq!( + store.load("identity").unwrap(), + Some("nsec1test".to_string()) + ); +} + +// ── Cross-process race tests (require real OS keychain) ──────────────── + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn test_stale_warm_cache_add_observes_prior_write() { + // Simulates the cross-process race that stranded Will's agent keys. + // + // Setup: two SecretStore instances for the same service (= two + // "processes" with separate caches). Process A warms its cache to + // {k1}. Process B then writes {k1, k2}. Without the fix, A's next + // mutate_blob would build from its stale {k1} cache and write + // {k1, k3}, silently dropping k2. With the fix, A always re-reads + // from the keychain inside the lock, so the result is {k1, k2, k3}. + let svc = "buzz-test-race-stale-cache"; + + // Clean state. + let setup = SecretStore::keyring(svc); + let _ = setup.delete("k1"); + let _ = setup.delete("k2"); + let _ = setup.delete("k3"); + + // Process A: write k1, warming its cache. + let store_a = SecretStore::keyring(svc); + store_a.store("k1", "v1").unwrap(); + + // Process B: write k2 (separate instance = separate cache). + let store_b = SecretStore::keyring(svc); + store_b.store("k2", "v2").unwrap(); + + // Process A: write k3. With the fix, A re-reads inside the lock and + // sees {k1, k2} before appending k3 — result must be {k1, k2, k3}. + store_a.store("k3", "v3").unwrap(); + + // Verify via a third reader (clean cache). + let reader = SecretStore::keyring(svc); + assert_eq!( + reader.load("k1").unwrap(), + Some("v1".to_string()), + "k1 must survive" + ); + assert_eq!( + reader.load("k2").unwrap(), + Some("v2".to_string()), + "k2 must not be dropped" + ); + assert_eq!( + reader.load("k3").unwrap(), + Some("v3".to_string()), + "k3 must be written" + ); + + // Cleanup. + let _ = reader.delete("k1"); + let _ = reader.delete("k2"); + let _ = reader.delete("k3"); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn test_concurrent_adds_neither_key_dropped() { + // Two sequential stores from distinct instances (simulating two + // processes each adding one key) must both be durably visible. + let svc = "buzz-test-race-concurrent-add"; + + let setup = SecretStore::keyring(svc); + let _ = setup.delete("agent_a"); + let _ = setup.delete("agent_b"); + + let store1 = SecretStore::keyring(svc); + store1.store("agent_a", "nsec1aaa").unwrap(); + + let store2 = SecretStore::keyring(svc); + store2.store("agent_b", "nsec1bbb").unwrap(); + + let reader = SecretStore::keyring(svc); + assert_eq!( + reader.load("agent_a").unwrap(), + Some("nsec1aaa".to_string()), + "agent_a must not be dropped" + ); + assert_eq!( + reader.load("agent_b").unwrap(), + Some("nsec1bbb".to_string()), + "agent_b must not be dropped" + ); + + // Cleanup. + let _ = reader.delete("agent_a"); + let _ = reader.delete("agent_b"); +} + +#[test] +fn test_blob_lockfile_path_is_in_tmp_with_uid() { + // The lockfile must be at a deterministic per-user path under /tmp — + // invariant to $TMPDIR — so both a GUI-launched DMG (env-stripped by + // launchd) and a terminal-launched dev build resolve the same inode and + // achieve mutual exclusion. + let path = blob_lockfile_path("buzz-desktop"); + #[cfg(unix)] + { + let uid = unsafe { libc::getuid() }; + assert!( + path.starts_with("/tmp"), + "lockfile {path:?} must start with /tmp (not $TMPDIR)" + ); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + assert!( + name.contains(&uid.to_string()), + "lockfile {path:?} must contain uid {uid}" + ); + assert!( + name.contains("buzz-keychain"), + "lockfile name must contain 'buzz-keychain'" + ); + } + #[cfg(not(unix))] + { + assert!( + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("buzz-keychain")), + "lockfile name must contain 'buzz-keychain'" + ); + } +} + +#[test] +fn test_blob_lock_acquire_and_release() { + // Verify the advisory lock can be acquired and released without errors. + // This exercises the real flock/mutex path on the current platform. + let guard = acquire_blob_lock("buzz-test-lock-smoke"); + assert!( + guard.is_ok(), + "advisory lock acquire must succeed: {:?}", + guard.err() + ); + // Drop the guard — lock is released. A second acquire must succeed. + drop(guard); + let guard2 = acquire_blob_lock("buzz-test-lock-smoke"); + assert!( + guard2.is_ok(), + "advisory lock re-acquire after release must succeed: {:?}", + guard2.err() + ); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn mutate_blob_does_not_advance_cache_on_write_failure() { + // Copy-on-write safety: if `write_blob_raw` fails (denied prompt, + // transient outage, ACL rejection), the cache must stay at the last + // known durable state. A subsequent `store()` for the same key/value + // must NOT be skipped as a no-op — the equality check must compare + // against the durable cache, not an unpersisted candidate. + // + // This is a real-keychain integration test. Run locally with: + // cargo test -p buzz-desktop -- --ignored mutate_blob_does_not_advance + // + // On a machine with a reachable keychain the `store()` call succeeds + // (result.is_ok()) and the write-failure branch is skipped — the test + // still passes. On a machine where the write is denied (e.g., user + // clicks Deny in the macOS prompt) result.is_err() and the assertions + // below verify the cache invariant. We verify that after an error: + // 1. The cache is not advanced (the previously cached key is intact). + // 2. The failed key is not present (the dirty candidate was discarded). + let mut map = HashMap::new(); + map.insert("existing".to_string(), "durable_val".to_string()); + let store = SecretStore::with_cache("buzz-test-cow-write-fail", Some(map)); + + // Attempt to add a new key — this calls write_blob_raw against the + // real keychain; with copy-on-write the cache must remain at {existing} + // if the write fails. + let result = store.store("new_key", "new_val"); + + if result.is_err() { + // Write failed (e.g., user denied the keychain prompt): confirm + // cache was not advanced — the existing key is still intact and + // the new key was never committed to the in-memory state. + assert_eq!( + store.load("existing").unwrap(), + Some("durable_val".to_string()), + "cache must remain at last durable state after write failure" + ); + // load("new_key") goes through the unchanged cache (no entry), + // then attempts migrate_legacy_key which also fails on a denied + // keychain, returning either Ok(None) or Err — either is correct + // since the key was never durably stored. + let after = store.load("new_key"); + assert!( + matches!(after, Ok(None) | Err(_)), + "a key whose write failed must not be visible via load: {after:?}" + ); + } + // If result.is_ok() the write succeeded — the cache-integrity invariant + // does not apply to the success path; no assertion needed here. +} + +#[test] +fn availability_error_discriminator() { + assert!(is_keyring_availability_error("dbus connection failed")); + assert!(is_keyring_availability_error( + "org.freedesktop.secrets not provided" + )); + assert!(is_keyring_availability_error("No Secret Service")); + assert!(is_keyring_availability_error( + "Platform secure storage failure" + )); + // A plain "not found" is per-entry, not an availability failure. + assert!(!is_keyring_availability_error("entry not found")); +} + +#[cfg(target_os = "macos")] +#[test] +fn dpk_error_discriminators() { + // errSecMissingEntitlement = -34018 signals unsigned dev build. + let e = SFError::from_code(-34018); + assert!(is_dpk_unavailable(&e)); + assert!(!is_not_found(&e)); + // errSecItemNotFound = -25300 is not a DPK-unavailable error. + let e = SFError::from_code(-25300); + assert!(is_not_found(&e)); + assert!(!is_dpk_unavailable(&e)); +} + +// Integration tests that exercise the real OS keychain. Skipped in CI +// (unsigned builds lack keychain entitlements); run locally with: +// cargo test -p buzz-desktop -- --ignored blob_ +// +// Each test uses a unique service name to avoid cross-test pollution. + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn blob_stores_and_retrieves_multiple_keys() { + let store = SecretStore::keyring("buzz-test-blob-multi"); + store.store("key_a", "val_a").unwrap(); + store.store("key_b", "val_b").unwrap(); + assert_eq!(store.load("key_a").unwrap(), Some("val_a".to_string())); + assert_eq!(store.load("key_b").unwrap(), Some("val_b".to_string())); + assert_eq!(store.load("key_c").unwrap(), None); + // Cleanup. + let _ = store.delete("key_a"); + let _ = store.delete("key_b"); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn blob_probe_present_absent_unreachable() { + let store = SecretStore::keyring("buzz-test-blob-probe"); + // No blob yet — key absent, backend reachable. + assert_eq!(store.probe("identity"), KeyringProbe::ReachableButEmpty); + store.store("identity", "nsec1test").unwrap(); + // Key now present. + assert_eq!(store.probe("identity"), KeyringProbe::Present); + // Different key — blob exists but key absent. + assert_eq!(store.probe("other"), KeyringProbe::ReachableButEmpty); + // Cleanup. + let _ = store.delete("identity"); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn blob_delete_removes_key_not_others() { + let store = SecretStore::keyring("buzz-test-blob-delete"); + store.store("keep", "keep_val").unwrap(); + store.store("remove", "remove_val").unwrap(); + store.delete("remove").unwrap(); + assert_eq!(store.load("keep").unwrap(), Some("keep_val".to_string())); + assert_eq!(store.load("remove").unwrap(), None); + // Cleanup. + let _ = store.delete("keep"); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn blob_migration_from_per_key_entry() { + let svc = "buzz-test-blob-migration"; + let key = "identity"; + let value = "nsec1migrationtest"; + + // Seed a per-key entry (old format) — no blob exists. + let entry = keyring_entry(svc, key).unwrap(); + entry.set_password(value).unwrap(); + + // Fresh store — no blob in the keychain yet. + let store = SecretStore::keyring(svc); + + // probe should find the legacy key. + assert_eq!(store.probe(key), KeyringProbe::Present); + + // load should migrate it into the blob and return the value. + assert_eq!(store.load(key).unwrap(), Some(value.to_string())); + + // Old per-key entry should be cleaned up. + let entry = keyring_entry(svc, key).unwrap(); + assert!(matches!(entry.get_password(), Err(keyring::Error::NoEntry))); + + // Key is now in the blob — probe confirms. + let store2 = SecretStore::keyring(svc); + assert_eq!(store2.probe(key), KeyringProbe::Present); + assert_eq!(store2.load(key).unwrap(), Some(value.to_string())); + + // Cleanup. + let _ = store2.delete(key); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn delete_all_with_legacy_cleanup_removes_per_key_identity() { + let svc = "buzz-test-delete-all-legacy"; + let key = "identity"; + let value = "nsec1legacytest"; + + // Seed a legacy per-key entry (old format, pre-blob migration). + let entry = keyring_entry(svc, key).unwrap(); + entry.set_password(value).unwrap(); + + // Also seed a blob with a different key to exercise the full path. + let store = SecretStore::keyring(svc); + store.store("agent:abc123", "nsec1agent").unwrap(); + + // Legacy per-key identity should be discoverable via probe. + let store2 = SecretStore::keyring(svc); + assert_eq!(store2.probe(key), KeyringProbe::Present); + + // Wipe everything via the sign-out path. + store2.delete_all_with_legacy_cleanup().unwrap(); + + // Fresh store — neither the blob nor the per-key entry should remain. + let store3 = SecretStore::keyring(svc); + assert_eq!( + store3.probe(key), + KeyringProbe::ReachableButEmpty, + "per-key identity must not survive delete_all_with_legacy_cleanup" + ); + assert_eq!( + store3.load(key).unwrap(), + None, + "load must not resurrect the legacy per-key identity" + ); + // Agent key should also be gone. + assert_eq!(store3.load("agent:abc123").unwrap(), None); +} diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index fcdc29fc5fd..0fee9df1bf5 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -1,5 +1,8 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useHostRegistration } from "@/features/hosts/useHostRegistration"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { startBootWarm } from "@/features/agents/acpRuntimesQuery"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; @@ -22,6 +25,13 @@ export function useAppShellLifecycleEffects({ }: AppShellLifecycleEffectsOptions) { // Event-driven reconnect: network online / focus / visibility short-circuit // the backoff timer when the relay session is degraded (CMD+R gap G1). + const { activeCommunity } = useCommunities(); + const { data: identity } = useIdentityQuery(); + // Huddle companion windows must not create a second host publisher. + useHostRegistration( + desktopBadgeEnabled ? identity?.pubkey : undefined, + activeCommunity?.relayUrl, + ); useRelayResumeTriggers(); useForegroundQueryRefresh(); diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 3daf4fa78cc..97677231526 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -623,7 +623,8 @@ export function useStopManagedAgentMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (pubkey: string) => stopManagedAgent(pubkey), + mutationFn: (input: Parameters[0]) => + stopManagedAgent(input), onSettled: () => { invalidateManagedAgentQueriesInBackground(queryClient); }, diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2e..3daa9ebca1d 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -12,6 +12,7 @@ function agent(overrides = {}) { name: "Mesh Agent", personaId: null, relayUrl: "ws://localhost:3000", + selectedRelayUrl: "ws://localhost:3000", acpCommand: "buzz-acp", agentCommand: "goose", agentArgs: [], @@ -56,7 +57,10 @@ test("relay-mesh agents delegate start to the backend preflight", async () => { calledWith = pubkey; }, }); - assert.equal(calledWith, meshAgent.pubkey); + assert.deepEqual(calledWith, { + pubkey: meshAgent.pubkey, + expectedRelayUrl: meshAgent.selectedRelayUrl, + }); // Backend preflight failures (e.g. no live serve target) propagate as-is. await assert.rejects( @@ -78,7 +82,10 @@ test("ordinary local agents still start normally", async () => { calledWith = pubkey; }, }); - assert.equal(calledWith, "deadbeef".repeat(8)); + assert.deepEqual(calledWith, { + pubkey: "deadbeef".repeat(8), + expectedRelayUrl: "ws://localhost:3000", + }); }); // --- respawnManagedAgentWithRules: stop→clear→start boundary tests ----------- @@ -166,3 +173,67 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +test("ordinary Stop preserves clicked generation even when a successor appears", async () => { + const { stopManagedAgentWithRules } = await import( + "./managedAgentControlActions.ts" + ); + const clicked = agent({ + status: "running", + selectedRunId: "aa".repeat(16), + selectedRelayUrl: "wss://clicked.example", + }); + let request; + await stopManagedAgentWithRules({ + agent: clicked, + channels: [], + relayAgents: [], + stopManagedAgent: async (selected) => { + clicked.selectedRunId = "bb".repeat(16); + clicked.selectedRelayUrl = "wss://successor.example"; + request = selected; + }, + }); + assert.deepEqual(request, { + pubkey: clicked.pubkey, + selectedRunId: "aa".repeat(16), + expectedRelayUrl: "wss://clicked.example", + }); +}); + +test("Restart retains clicked community across the Stop await", async () => { + const clicked = agent({ status: "running", selectedRunId: "aa".repeat(16) }); + const original = clicked.selectedRelayUrl; + let stop, start; + await respawnManagedAgentWithRules({ + agent: clicked, + stopManagedAgent: async (input) => { + stop = input; + await Promise.resolve(); + clicked.selectedRelayUrl = "wss://switched.example"; + }, + startManagedAgent: async (input) => { + start = input; + }, + }); + assert.equal(stop.expectedRelayUrl, original); + assert.equal(start.expectedRelayUrl, original); + assert.equal(stop.selectedRunId, "aa".repeat(16)); +}); + +test("missing community fails before either Restart leg, never falls back to record pin", async () => { + let calls = 0; + await assert.rejects( + respawnManagedAgentWithRules({ + agent: agent({ status: "running", selectedRelayUrl: null }), + stopManagedAgent: async () => { + calls++; + }, + startManagedAgent: async () => { + calls++; + }, + }), + /selected community/, + ); + assert.equal(calls, 0); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cce..60b6f80e6ca 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -12,8 +12,31 @@ type DeleteManagedAgentInput = { forceRemoteDelete?: boolean; }; -type StartManagedAgent = (pubkey: string) => Promise; -type StopManagedAgent = (pubkey: string) => Promise; +export type StartManagedAgent = (input: { + pubkey: string; + expectedRelayUrl: string; +}) => Promise; +type StopManagedAgent = (input: { + pubkey: string; + selectedRunId?: string | null; + expectedRelayUrl?: string | null; +}) => Promise; + +function selectedStop(agent: ManagedAgent) { + return { + pubkey: agent.pubkey, + selectedRunId: agent.selectedRunId, + expectedRelayUrl: agent.selectedRelayUrl, + }; +} +function selectedStart(agent: ManagedAgent) { + if (!agent.selectedRelayUrl) { + throw new Error( + "Cannot start without a selected community; refresh agent status", + ); + } + return { pubkey: agent.pubkey, expectedRelayUrl: agent.selectedRelayUrl }; +} type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise; type ManagedAgentChannelContext = { @@ -85,7 +108,7 @@ export async function startManagedAgentWithRules({ // Relay-mesh agents are no longer blocked here: the backend start preflight // (ensure_relay_mesh_for_record) re-resolves a live serve target and dials // it, failing with an actionable error when no peer serves the model. - await startManagedAgent(agent.pubkey); + await startManagedAgent(selectedStart(agent)); } export async function respawnManagedAgentWithRules({ @@ -101,12 +124,13 @@ export async function respawnManagedAgentWithRules({ * clear stale working badges at the right boundary. */ onStopped?: () => void; }) { + const start = selectedStart(agent); if (agent.backend.type === "local" && isManagedAgentActive(agent)) { - await stopManagedAgent(agent.pubkey); + await stopManagedAgent(selectedStop(agent)); onStopped?.(); } - await startManagedAgent(agent.pubkey); + await startManagedAgent(start); } export async function stopManagedAgentWithRules({ @@ -137,7 +161,7 @@ export async function stopManagedAgentWithRules({ }; } - await stopManagedAgent(agent.pubkey); + await stopManagedAgent(selectedStop(agent)); return {}; } diff --git a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs index f57c7f8154f..a45ddb5e64c 100644 --- a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs +++ b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isOtherSetupAgent } from "./otherSetupAgent.ts"; +import { + isOtherSetupAgent, + isOwnedAgentNotManagedOnDevice, +} from "./otherSetupAgent.ts"; const OWNER = "a".repeat(64); const AGENT = "b".repeat(64); @@ -20,7 +23,7 @@ test("fails closed while the local managed directory is unresolved", () => { ); }); -test("labels a viewer-owned non-local identity as another setup", () => { +test("labels a viewer-owned identity as not managed on this device", () => { assert.equal( isOtherSetupAgent({ agentDirectoriesReady: true, @@ -33,3 +36,38 @@ test("labels a viewer-owned non-local identity as another setup", () => { true, ); }); + +test("a locally managed provider is not labeled as another device", () => { + assert.equal( + isOtherSetupAgent({ + agentDirectoriesReady: true, + currentPubkey: OWNER, + managedAgents: [{ pubkey: AGENT, backend: { type: "provider" } }], + profileOwnerPubkey: OWNER, + pubkey: AGENT, + relayAgents: [], + }), + false, + ); +}); + +for (const [name, overrides, expected] of [ + ["owned absent key", {}, true], + ["loading local inventory", { localInventoryReady: false }, false], + ["exact local provider record", { isLocallyManaged: true }, false], + ["different owner", { ownerPubkey: "b".repeat(64) }, false], + ["unknown ownership", { ownerPubkey: null }, false], +]) { + test(`shared provenance: ${name}`, () => { + assert.equal( + isOwnedAgentNotManagedOnDevice({ + currentPubkey: "a".repeat(64), + ownerPubkey: "A".repeat(64), + localInventoryReady: true, + isLocallyManaged: false, + ...overrides, + }), + expected, + ); + }); +} diff --git a/desktop/src/features/agents/lib/otherSetupAgent.ts b/desktop/src/features/agents/lib/otherSetupAgent.ts index 63438a983fd..f94215e1f3a 100644 --- a/desktop/src/features/agents/lib/otherSetupAgent.ts +++ b/desktop/src/features/agents/lib/otherSetupAgent.ts @@ -1,6 +1,7 @@ import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +/** Owned identity absent from the loaded local inventory; not evidence of hosting location. */ export function isOtherSetupAgent({ agentDirectoriesReady, currentPubkey, @@ -32,8 +33,31 @@ export function isOtherSetupAgent({ )?.ownerPubkey; const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey; + return isOwnedAgentNotManagedOnDevice({ + currentPubkey, + ownerPubkey, + localInventoryReady: agentDirectoriesReady, + isLocallyManaged: false, + }); +} + +/** Presentation provenance only; neither hosting location nor availability. */ +export function isOwnedAgentNotManagedOnDevice({ + currentPubkey, + ownerPubkey, + localInventoryReady, + isLocallyManaged, +}: { + currentPubkey?: string; + ownerPubkey?: string | null; + localInventoryReady: boolean; + isLocallyManaged: boolean; +}): boolean { return Boolean( - ownerPubkey && + localInventoryReady && + !isLocallyManaged && + currentPubkey && + ownerPubkey && normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey), ); } diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 96a3abc78d4..5415b05e5f9 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -185,17 +185,21 @@ export function useManagedAgentRuntimeAction() { action, pubkey, relayUrl, + selectedRunId, }: { action: "start" | "stop" | "restart"; pubkey: string; relayUrl: string; + selectedRunId?: string | null; }) => { - if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl); + const stopSelected = (pubkey: string, relayUrl: string) => + stopManagedAgentRuntime(pubkey, relayUrl, selectedRunId); + if (action === "stop") return stopSelected(pubkey, relayUrl); if (action === "restart") { return restartManagedAgentPair( pubkey, relayUrl, - stopManagedAgentRuntime, + stopSelected, clearActiveTurnsForAgentOnStop, startManagedAgentRuntime, ); diff --git a/desktop/src/features/agents/ui/AgentHostMarker.tsx b/desktop/src/features/agents/ui/AgentHostMarker.tsx new file mode 100644 index 00000000000..4d42c8b634a --- /dev/null +++ b/desktop/src/features/agents/ui/AgentHostMarker.tsx @@ -0,0 +1,46 @@ +import { cn } from "@/shared/lib/cn"; +import { Cloud } from "lucide-react"; +import { + locationLabels, + type PresenceRun, +} from "@/features/presence/runPresence"; +import { OtherSetupAgentMarker } from "./OtherSetupAgentMarker"; + +/** Provenance never supplies location; only unexpired relay run leases do. */ +export function AgentHostMarker({ + runs, + now, + otherSetup = false, + className, + testId, +}: { + runs?: PresenceRun[]; + now: number; + otherSetup?: boolean; + className?: string; + testId?: string; +}) { + const labels = locationLabels(runs, now); + if (!labels.length) + return otherSetup ? ( + + ) : null; + const title = `Running on ${labels.join(", ")}`; + return ( + + + ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 22b5326223c..7c955bb4b6d 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { HostsSection } from "@/features/hosts/HostsSection"; import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, @@ -218,6 +219,7 @@ export function AgentsView() { title="Agents" />
+ + ); +} diff --git a/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx index 19ef31bd75b..2bd68473c71 100644 --- a/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx +++ b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx @@ -1,8 +1,14 @@ import { Cloud } from "lucide-react"; +import { LiveAgentHostMarker } from "./LiveAgentHostMarker"; +import { + useKnownAgentPubkeys, + useIsOtherSetupAgent, +} from "../useKnownAgentPubkeys"; + import { cn } from "@/shared/lib/cn"; -const OTHER_SETUP_LABEL = "From another Buzz setup"; +const OTHER_SETUP_LABEL = "Not managed on this device"; export function OtherSetupAgentMarker({ className, @@ -23,3 +29,27 @@ export function OtherSetupAgentMarker({ ); } + +/** Connected marker for identity details; shares the app's directory subscriptions. */ +export function AgentManagementMarker({ + pubkey, + ownerPubkey, + className, + testId, +}: { + pubkey?: string | null; + ownerPubkey?: string | null; + className?: string; + testId?: string; +}) { + const show = useIsOtherSetupAgent(pubkey, ownerPubkey); + const knownAgents = useKnownAgentPubkeys(); + return pubkey && (show || knownAgents.has(pubkey.toLowerCase())) ? ( + + ) : null; +} diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs b/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs new file mode 100644 index 00000000000..7c2c82f0bfc --- /dev/null +++ b/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import { createElement } from "react"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + KnownAgentPubkeysProvider, + useIsOtherSetupAgent, +} from "./useKnownAgentPubkeys.tsx"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}); +after(() => dom.window.close()); + +test("provenance context follows exact local inventory and rejects failed cached reads", async () => { + const { act, renderHook, cleanup } = await import("@testing-library/react"); + const owner = "a".repeat(64), + remote = "b".repeat(64), + local = "c".repeat(64); + const client = new QueryClient({ + defaultOptions: { + queries: { enabled: false, retry: false, staleTime: Infinity }, + }, + }); + client.setQueryData(["identity"], { pubkey: owner }); + client.setQueryData( + ["managed-agents"], + [{ pubkey: local, status: "stopped" }], + ); + client.setQueryData( + ["relay-agents"], + [{ pubkey: remote, ownerPubkey: owner }], + ); + const wrapper = ({ children }) => + createElement( + QueryClientProvider, + { client }, + createElement(KnownAgentPubkeysProvider, null, children), + ); + const { result } = renderHook( + () => [ + useIsOtherSetupAgent(remote), + useIsOtherSetupAgent(local, owner), + useIsOtherSetupAgent("d".repeat(64), owner), + ], + { wrapper }, + ); + assert.deepEqual(result.current, [true, false, true]); + await act(async () => + client.setQueryData( + ["managed-agents"], + [{ pubkey: remote, status: "deployed" }], + ), + ); + assert.deepEqual(result.current, [false, true, true]); + await act(async () => { + client + .getQueryCache() + .find({ queryKey: ["managed-agents"] }) + .setState({ error: new Error("inventory unavailable"), status: "error" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + assert.deepEqual(result.current, [false, false, false]); + cleanup(); + client.clear(); +}); diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.tsx b/desktop/src/features/agents/useKnownAgentPubkeys.tsx index e9fe7b9a9b0..49f80054bc2 100644 --- a/desktop/src/features/agents/useKnownAgentPubkeys.tsx +++ b/desktop/src/features/agents/useKnownAgentPubkeys.tsx @@ -5,10 +5,25 @@ import { useRelayAgentsQuery, } from "@/features/agents/hooks"; import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; -import { useStableSet } from "@/shared/hooks/useStableReference"; +import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { isAgentDirectoryReady } from "./lib/agentAutocompleteEligibility"; +import { isOwnedAgentNotManagedOnDevice } from "./lib/otherSetupAgent"; const EMPTY_KNOWN_AGENT_PUBKEYS: ReadonlySet = new Set(); +const AgentManagementContext = React.createContext<{ + currentPubkey?: string; + localInventoryReady: boolean; + localPubkeys: ReadonlySet; + relayOwners: ReadonlyMap; +}>({ + localInventoryReady: false, + localPubkeys: new Set(), + relayOwners: new Map(), +}); + const KnownAgentPubkeysContext = React.createContext>( EMPTY_KNOWN_AGENT_PUBKEYS, ); @@ -39,8 +54,36 @@ export function KnownAgentPubkeysProvider({ }: { children: React.ReactNode; }) { - const managedAgents = useManagedAgentsQuery().data; - const relayAgents = useRelayAgentsQuery().data; + const managedQuery = useManagedAgentsQuery(); + const relayQuery = useRelayAgentsQuery(); + const currentPubkey = useIdentityQuery().data?.pubkey; + const managedAgents = managedQuery.data; + const relayAgents = relayQuery.data; + const localPubkeys = useStableSet( + new Set( + (managedAgents ?? []).map((agent) => normalizePubkey(agent.pubkey)), + ), + ); + const relayOwners = useStableMap( + new Map( + isAgentDirectoryReady(relayQuery) + ? (relayAgents ?? []).map((agent) => [ + normalizePubkey(agent.pubkey), + agent.ownerPubkey, + ]) + : [], + ), + ); + const localInventoryReady = isAgentDirectoryReady(managedQuery); + const management = React.useMemo( + () => ({ + currentPubkey, + localInventoryReady, + localPubkeys, + relayOwners, + }), + [currentPubkey, localInventoryReady, localPubkeys, relayOwners], + ); const merged = React.useMemo( () => mergeKnownAgentPubkeys(managedAgents, relayAgents), @@ -50,7 +93,9 @@ export function KnownAgentPubkeysProvider({ return ( - {children} + + {children} + ); } @@ -82,3 +127,21 @@ export function KnownAgentPubkeysProvider({ export function useKnownAgentPubkeys(): ReadonlySet { return React.useContext(KnownAgentPubkeysContext); } + +/** Shared provenance without per-row query observers; exact keys, never personas. */ +export function useIsOtherSetupAgent( + pubkey?: string | null, + profileOwnerPubkey?: string | null, +): boolean { + const state = React.useContext(AgentManagementContext); + const key = normalizePubkey(pubkey ?? ""); + return ( + Boolean(key) && + isOwnedAgentNotManagedOnDevice({ + currentPubkey: state.currentPubkey, + ownerPubkey: profileOwnerPubkey ?? state.relayOwners.get(key), + localInventoryReady: state.localInventoryReady, + isLocallyManaged: state.localPubkeys.has(key), + }) + ); +} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 3ccabdf7536..e61979efc21 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -14,6 +14,7 @@ import { ProfileAvatarWithStatus, scaleProfileAvatarStatusGeometry, } from "@/features/profile/ui/ProfileAvatarWithStatus"; +import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; import type { Channel, PresenceStatus } from "@/shared/api/types"; @@ -194,9 +195,17 @@ export function ChannelScreenHeader({ ) : undefined } statusBadge={ - + <> + + {!isGroupDm && activeDmParticipant ? ( + + ) : null} + } title={activeChannelTitle} transparentChrome={transparentChrome} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index c6f32d5fc93..ebefb737ca4 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -18,7 +18,7 @@ import { getManagedAgentPrimaryActionLabel, isManagedAgentActive, } from "@/features/agents/lib/managedAgentControlActions"; -import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; +import { LiveAgentHostMarker } from "@/features/agents/ui/LiveAgentHostMarker"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { @@ -198,11 +198,11 @@ export function MembersSidebarMemberCard({
- {showOtherSetupMarker ? ( - - ) : null} + ) : (
diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index cc8f4062210..e10db985deb 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -159,6 +159,7 @@ export function useMembersSidebarActions({ action, pubkey: agent.pubkey, relayUrl, + selectedRunId: runtime?.runId, }); setActionNoticeMessage( action === "stop" diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index c565ee0f7b4..9062097b38c 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -49,6 +49,9 @@ import type { Community } from "./types"; * community starts with a clean slate. Hook-managed singletons * (e.g. ChannelMuteSyncManager, ChannelSectionSyncManager) are * destroyed via effect cleanup and do not need entries here. + * Host publication has no decrypted singleton cache: its scoped pending + * journal contains signed ciphertext only. hostNativeDrain is a data-free + * native-work barrier and deliberately survives switching/keyed remounts. * See AGENTS.md "Community Switching" for the full contract. */ async function resetCommunityState({ diff --git a/desktop/src/features/hosts/HostMoveSection.tsx b/desktop/src/features/hosts/HostMoveSection.tsx new file mode 100644 index 00000000000..db4da87d716 --- /dev/null +++ b/desktop/src/features/hosts/HostMoveSection.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { activeRuns } from "@/features/presence/runPresence"; +import { usePresenceRuns } from "@/features/presence/usePresenceRuns"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { invokeTauri } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; +import type { HostRow } from "./registration"; +import { moveStatus, moveUnavailable } from "./moveSelection"; +import { START_REFRESH, useHostStartProgress } from "./useHostStart"; + +/** No optimistic removal of the source, and no browser-owned Move state machine. */ +export function HostMoveSection({ + agent, + rows, +}: { + agent: string; + rows: HostRow[]; +}) { + const { activeCommunity } = useCommunities(); + const { data: identity } = useIdentityQuery(); + const presence = usePresenceRuns( + agent ? [agent, ...rows.map((r) => r.host)] : [], + ); + const progress = useHostStartProgress(); + const [sourceId, setSourceId] = useState(""); + const [destinationId, setDestinationId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const runs = activeRuns(presence.data?.[agent], presence.now); + const source = runs.find( + (r) => `${agent}:${r.location?.host}:${r.run}` === sourceId, + ); + const sourceRow = rows.find((r) => r.host === source?.location?.host); + const destination = rows.find((r) => r.host === destinationId); + const online = (row: HostRow) => + presence.isError || !presence.data + ? undefined + : activeRuns(presence.data[row.host], presence.now).length > 0; + const reason = destination + ? moveUnavailable( + source, + destination, + agent, + online(destination), + runs.some((r) => r.location?.host === destination.host), + ) + : "Choose a destination"; + const move = async ( + sourceRow: HostRow | undefined, + destination: HostRow | undefined, + run: string | undefined, + ) => { + const config = destination?.report?.provisioned?.find( + (c) => c.agent === agent, + ); + if ( + !identity || + !activeCommunity || + !sourceRow || + !destination || + !run || + !config + ) + return; + setBusy(true); + setError(undefined); + try { + await invokeTauri("queue_host_move", { + expectedOwner: identity.pubkey, + expectedRelay: activeCommunity.relayUrl, + sourceRegistration: sourceRow.registration, + run, + destinationRegistration: destination.registration, + agent, + runtime: config.runtime, + revision: config.revision, + }); + window.dispatchEvent(new Event(START_REFRESH)); + } catch (e) { + setError( + typeof e === "string" + ? e + : "Could not save Move. Refresh the hosts and retry; an unsaved Move cannot dispatch Stop.", + ); + } finally { + setBusy(false); + } + }; + if (!agent) return null; + return ( +
+

Move one active instance

+

+ Stop the selected instance first, then start this same agent in a fresh + session on another host. No workspace, files, keys, or configuration are + copied. Other instances keep running. +

+ + + + {error ? ( +

+ {error} +

+ ) : null} + {progress.moves + .filter((m) => m.agent === agent) + .map((m) => { + const live = runs.find( + (r) => + r.run === m.destination_run && + r.location?.host === m.destination_host, + ); + const label = (host: string) => + rows.find((r) => r.host === host)?.report?.name ?? + "Registered host"; + return ( +
+

+ {label(m.source_host)} → {label(m.destination_host)} · Run{" "} + {m.source_run} +

+

{moveStatus(m.status)}

+ {live ? ( +

Live location: {live.location?.label} (matching new run)

+ ) : ( +

No matching live destination location observed.

+ )} + {m.error ?

{m.error}

: null} + {m.status === "stopped_waiting_destination" ? ( + + ) : null} + +
+ ); + })} +
+ ); +} diff --git a/desktop/src/features/hosts/HostStartButton.tsx b/desktop/src/features/hosts/HostStartButton.tsx new file mode 100644 index 00000000000..e0926722305 --- /dev/null +++ b/desktop/src/features/hosts/HostStartButton.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { invokeTauri } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; +import type { HostRow } from "./registration"; +import { startStatus, startUnavailable } from "./startSelection"; +import { START_REFRESH, useHostStartProgress } from "./useHostStart"; +import { usePresenceRuns } from "@/features/presence/usePresenceRuns"; +import { activeRuns } from "@/features/presence/runPresence"; + +export function HostStartButton({ + row, + agent, + online, +}: { + row: HostRow; + agent: string; + online?: boolean; +}) { + const { activeCommunity } = useCommunities(); + const { data: identity } = useIdentityQuery(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const progress = useHostStartProgress(); + const operation = progress.operations + .filter( + (op) => + op.action !== "stop" && + op.agent === agent && + op.host === row.host && + op.current, + ) + .sort((a, b) => a.created_at - b.created_at) + .at(-1); + const runs = usePresenceRuns(agent ? [agent] : []); + const live = + operation && + activeRuns(runs.data?.[agent], runs.now).find( + (run) => run.run === operation.run && run.location?.host === row.host, + ); + const reason = startUnavailable(row, agent, online); + const config = row.report?.provisioned?.find((c) => c.agent === agent); + const start = async (fresh: boolean) => { + if (!config || !identity || !activeCommunity) return; + setBusy(true); + setError(undefined); + try { + await invokeTauri("queue_host_start", { + expectedOwner: identity.pubkey, + expectedRelay: activeCommunity.relayUrl, + registration: row.registration, + agent, + runtime: config.runtime, + revision: config.revision, + newAttemptAfter: fresh ? operation?.operation : undefined, + }); + window.dispatchEvent(new Event(START_REFRESH)); + } catch { + setError( + fresh + ? "A new session requires a signed confirmed Stop of the prior run (or a rejected Start). No replacement was queued. Refresh and retry the saved operation if its outcome is unknown." + : "Could not save Start. Check destination registration and retry; no unsaved command was sent.", + ); + } finally { + setBusy(false); + } + }; + const disabled = busy || !!reason || !identity || !activeCommunity; + return ( +
+ + {operation ? ( + + ) : null} + {reason ? ( +

+ {reason}. Use Agents on that Desktop to complete setup; keys and files + are not copied. +

+ ) : null} + {operation ? ( +

+ {startStatus(operation.status)} + {live + ? ` · Live location: ${live.location?.label} (same run)` + : " · No matching live location observed"} +

+ ) : null} + {error || operation?.error || progress.error ? ( +

+ {error ?? operation?.error ?? progress.error} +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/hosts/HostsSection.tsx b/desktop/src/features/hosts/HostsSection.tsx new file mode 100644 index 00000000000..90b8b6d36f4 --- /dev/null +++ b/desktop/src/features/hosts/HostsSection.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { HostMoveSection } from "./HostMoveSection"; +import { HostStartButton } from "./HostStartButton"; +import { Laptop, LockKeyhole, RefreshCw } from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { activeRuns } from "@/features/presence/runPresence"; +import { usePresenceRuns } from "@/features/presence/usePresenceRuns"; +import { HOST_REFRESH, useHostSnapshot } from "./useHostRegistration"; + +export function HostsSection() { + const snapshot = useHostSnapshot(); + const { data: agents } = useManagedAgentsQuery(); + const [selectedAgent, setSelectedAgent] = useState(""); + const presence = usePresenceRuns(snapshot.rows.map((row) => row.host)); + return ( +
+
+
+

+ Your hosts +

+

+ + Only visible to you in this community. +

+
+ +
+ {snapshot.error ? ( +

+ Host sync failed: {snapshot.error}. Registration requires a relay with + host support. +

+ ) : null} + {!snapshot.rows.length ? ( +

+ {snapshot.checking + ? "Checking this computer and its registration…" + : "No relay-confirmed hosts to show."} +

+ ) : null} + + +
+ {snapshot.rows.map((row) => ( +
+
+
+ +

+ {row.report?.name ?? "Registered host"} +

+
+ + {presence.isError || !presence.data + ? "Presence unknown" + : activeRuns(presence.data[row.host], presence.now).length + ? "Online" + : "Offline"} + +
+

+ {row.host === snapshot.local?.host + ? `This computer · Public name: Desktop ${row.host.slice(0, 8)} · ` + : ""} + {row.report + ? `${row.report.os} · ${row.report.arch} · Desktop ${row.report.launcher_version}` + : "Waiting for a capability profile"} +

+ {row.report ? ( +
    + {row.report.runtimes.map((runtime) => ( +
  • + {runtime.label} + + {runtime.availability.replaceAll("_", " ")} ·{" "} + {runtime.auth_status.replaceAll("_", " ")} + +
  • + ))} +
+ ) : null} +

+ {row.event + ? `Profile updated ${new Date(row.event.created_at * 1000).toLocaleTimeString()}. ` + : ""} + {row.report?.accepts_start + ? "Start receiver advertised; destination rechecks configuration at launch." + : "Remote Start is unavailable on this host."} +

+ 0 + } + /> +
+ ))} +
+

+ Capabilities update only when they change and remain visible offline. + Online means Desktop is renewing its three-minute presence lease, not + that an agent can start. History is checked across all pages before + publishing registration or capability changes. +

+
+ ); +} diff --git a/desktop/src/features/hosts/history.test.mjs b/desktop/src/features/hosts/history.test.mjs new file mode 100644 index 00000000000..1a2f7ecfd22 --- /dev/null +++ b/desktop/src/features/hosts/history.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fetchHostHistory } from "./history.ts"; +import { fixture } from "./hostTestFixtures.mjs"; + +const filter = { kinds: [50000], limit: 1000 }; +const event = (id, created_at = 100) => ({ + id: id.toString(16).padStart(64, "0"), + created_at, +}); + +test("keyset exhausts more than two full pages of same-second events", async () => { + const rows = Array.from({ length: 2501 }, (_, i) => event(i + 1)); + const calls = []; + const result = await fetchHostHistory( + async (f) => { + calls.push(f); + return rows + .filter((e) => !f.before_id || e.id > f.before_id) + .slice(0, f.limit) + .reverse(); + }, + filter, + () => {}, + ); + assert.deepEqual(result, rows); + assert.equal(calls.length, 3); + assert.equal(calls[1].until, 100); + assert.equal(calls[1].before_id, rows[999].id); +}); + +test("exact full page needs an empty successor, not an incomplete-history error", async () => { + let calls = 0; + const result = await fetchHostHistory( + async (f) => { + calls++; + return f.before_id + ? [] + : Array.from({ length: 1000 }, (_, i) => event(i + 1)); + }, + filter, + () => {}, + ); + assert.equal(result.length, 1000); + assert.equal(calls, 2); +}); + +test("old server ignoring the cursor fails closed rather than loops or suppresses a write", async () => { + const page = Array.from({ length: 1000 }, (_, i) => event(i + 1)); + await assert.rejects( + fetchHostHistory( + async () => [...page], + filter, + () => {}, + ), + /cursor/, + ); +}); + +test("failure or cancellation on a later page discards the partial history", async () => { + for (const cancel of [false, true]) { + let active = true; + let calls = 0; + await assert.rejects( + fetchHostHistory( + async () => { + calls++; + if (calls === 2) { + if (!cancel) throw new Error("offline"); + active = false; + return []; + } + return Array.from({ length: 1000 }, (_, i) => event(i + 1)); + }, + filter, + () => { + if (!active) throw new Error("cancelled"); + }, + ), + /offline|cancelled/, + ); + assert.equal(calls, 2); + } +}); + +test("registration and profile reconciliation both exceed 1000, preserving newest valid profile", async () => { + const f = fixture(); + await f.run(); + const registration = f.events[0]; + const profile = f.events[1]; + // All bindings target this host, as can happen after repeated older-client + // registration attempts. Newest profiles include unreadable ciphertext. + for (let i = 1; i <= 1100; i++) { + f.events.push({ ...registration, id: event(i + 500000).id }); + f.events.push({ + ...profile, + id: event(i + 600000).id, + created_at: 101, + decoded: undefined, + }); + } + f.setNow(103); + f.writes.length = 0; + const result = await f.run(); + assert.equal(result.rows.length, 1); + assert.equal(result.rows[0].event.id, profile.id); + assert.equal(f.writes.length, 0); +}); diff --git a/desktop/src/features/hosts/history.ts b/desktop/src/features/hosts/history.ts new file mode 100644 index 00000000000..b0c691240a7 --- /dev/null +++ b/desktop/src/features/hosts/history.ts @@ -0,0 +1,50 @@ +import type { RelayEvent } from "@/shared/api/types"; +import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; + +/** HTTP /query keyset extension; do not send this through a NIP-01 socket. */ +export type HostHistoryFilter = RelaySubscriptionFilter & { + before_id?: string; +}; + +/** Exhaust exact, primary-backed host pages before making a publication decision. */ +export async function fetchHostHistory( + fetchPage: (filter: HostHistoryFilter) => Promise, + filter: HostHistoryFilter, + check: () => void, +): Promise { + const events: RelayEvent[] = []; + const seen = new Set(); + let cursor: RelayEvent | undefined; + for (;;) { + check(); + const page = await fetchPage({ + ...filter, + ...(cursor ? { until: cursor.created_at, before_id: cursor.id } : {}), + }); + check(); + if (page.length > filter.limit || filter.limit <= 0) + throw new Error("Invalid host history page"); + // HTTP result order is not part of the client contract. Sort using the + // database's DESC timestamp / ASC event-ID keyset order. + page.sort( + (a, b) => + b.created_at - a.created_at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + for (const event of page) { + if ( + !/^[0-9a-f]{64}$/.test(event.id) || + !Number.isSafeInteger(event.created_at) || + event.created_at < 0 || + seen.has(event.id) || + (cursor && + (event.created_at > cursor.created_at || + (event.created_at === cursor.created_at && event.id <= cursor.id))) + ) + throw new Error("Host history cursor did not advance"); + seen.add(event.id); + events.push(event); + } + if (page.length < filter.limit) return events; + cursor = page[page.length - 1]; + } +} diff --git a/desktop/src/features/hosts/hostNativeDrain.test.mjs b/desktop/src/features/hosts/hostNativeDrain.test.mjs new file mode 100644 index 00000000000..fc117e2a698 --- /dev/null +++ b/desktop/src/features/hosts/hostNativeDrain.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useHostRegistration } from "./useHostRegistration.ts"; +import { hostNativeDrain } from "./hostNativeDrain.ts"; +import { hostQueryKey } from "./registration.ts"; +import { fixture } from "./hostTestFixtures.mjs"; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); +for (const switchKind of ["identity", "community"]) { + test(`actual registration hook waits across keyed ${switchKind} + query-provider remount`, async (t) => { + const saved = Object.fromEntries( + ["window", "document", "IS_REACT_ACT_ENVIRONMENT"].map((key) => [ + key, + globalThis[key], + ]), + ); + const dom = new JSDOM("
", { + url: "https://fixture.invalid", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const root = createRoot(document.getElementById("root")); + const calls = []; + const releases = []; + const f = fixture(); + dom.window.__TAURI_INTERNALS__ = { + invoke: (command, args) => { + assert.equal( + command, + "get_local_host", + "cancelled mounts must not connect or publish", + ); + calls.push(args.expectedOwner); + return new Promise((resolve) => + releases.push(async () => resolve(await f.bridge.local())), + ); + }, + }; + t.after(async () => { + await act(async () => root.unmount()); + for (const release of releases) await release(); + await hostNativeDrain.wait(); + await new Promise((resolve) => setTimeout(resolve, 10)); + dom.window.close(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + }); + const first = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity } }, + }); + const second = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity } }, + }); + const owner = "a".repeat(64), + relay = "wss://fixture.invalid"; + const nextOwner = switchKind === "identity" ? "c".repeat(64) : owner; + const nextRelay = + switchKind === "community" ? "wss://other.invalid" : relay; + function Publisher({ owner, relay }) { + useHostRegistration(owner, relay); + return null; + } + const tree = (key, client, owner, relay) => + React.createElement( + QueryClientProvider, + { key, client }, + React.createElement(Publisher, { key, owner, relay }), + ); + await act(async () => { + root.render(tree("old", first, owner, relay)); + await flush(); + }); + assert.deepEqual(calls, [owner]); + assert.ok(first.getQueryData(hostQueryKey(relay, owner))); + await act(async () => { + root.render(tree("new", second, nextOwner, nextRelay)); + await flush(); + }); + assert.deepEqual( + calls, + [owner], + "new native work cannot overtake old native discovery", + ); + assert.equal(first.getQueryData(hostQueryKey(relay, owner)), undefined); + assert.equal( + second.getQueryData(hostQueryKey(nextRelay, nextOwner)), + undefined, + ); + await act(async () => { + await releases[0](); + await flush(); + }); + assert.deepEqual(calls, [owner, nextOwner]); + await act(async () => root.unmount()); + await releases[1](); + await hostNativeDrain.wait(); + assert.equal( + second.getQueryData(hostQueryKey(nextRelay, nextOwner)), + undefined, + ); + first.clear(); + second.clear(); + }); +} diff --git a/desktop/src/features/hosts/hostNativeDrain.ts b/desktop/src/features/hosts/hostNativeDrain.ts new file mode 100644 index 00000000000..5f83a05113e --- /dev/null +++ b/desktop/src/features/hosts/hostNativeDrain.ts @@ -0,0 +1,15 @@ +/** + * Data-free native-work barrier shared across keyed React remounts. Only a + * Promise survives: no owner, relay, event, report, or query cache. This + * deliberately must NOT reset on community switching (that would lose the + * outstanding native work); decrypted state remains in the scoped component. + */ +export const hostNativeDrain = { + pending: Promise.resolve() as Promise, + wait(): Promise { + return this.pending; + }, + hold(drain: Promise): void { + this.pending = Promise.allSettled([this.pending, drain]).then(() => {}); + }, +}; diff --git a/desktop/src/features/hosts/hostTestFixtures.mjs b/desktop/src/features/hosts/hostTestFixtures.mjs new file mode 100644 index 00000000000..b7fedfe4c91 --- /dev/null +++ b/desktop/src/features/hosts/hostTestFixtures.mjs @@ -0,0 +1,135 @@ +import { reconcileHost, HOST_KIND, HOST_NAMESPACE } from "./registration.ts"; + +import { createHostPublicationJournal } from "./pendingPublication.ts"; + +export function memoryStorage() { + const entries = new Map(); + return { + entries, + getItem: (key) => entries.get(key) ?? null, + setItem: (key, value) => entries.set(key, value), + removeItem: (key) => entries.delete(key), + }; +} + +export function fixture({ legacy = false } = {}) { + let now = 100; + const owner = "a".repeat(64), + host = "b".repeat(64); + const payload = { + v: legacy ? 1 : 2, + name: "computer", + os: "macos", + arch: "aarch64", + launcher_version: "test", + runtimes: [ + { + id: "one", + label: "One", + availability: "available", + auth_status: "unknown", + }, + ], + accepts_start: false, + }; + const storage = memoryStorage(); + const journal = createHostPublicationJournal( + "wss://fixture.invalid", + owner, + storage, + ); + const decoded = new Map(); + const events = []; + const writes = []; + let count = 0; + let active = true; + const make = (label, extra = []) => ({ + id: String(now * 1000 + ++count).padStart(64, "0"), + kind: HOST_KIND, + pubkey: label === "registration" ? owner : host, + content: "random-ciphertext", + sig: "f".repeat(128), + created_at: now, + tags: [ + ["L", HOST_NAMESPACE], + ["l", label, HOST_NAMESPACE], + ["p", owner], + ["x", host], + ...extra, + ], + }); + const bridge = { + local: async () => ({ host, report: payload }), + registration: async () => make("registration"), + report: async (registration) => { + const event = { + ...make(legacy ? "report" : "profile", [ + ["e", registration.id], + ...(legacy ? [["valid_until", String(now + 180)]] : []), + ]), + decoded: structuredClone(payload), + }; + decoded.set(event.id, event.decoded); + return event; + }, + inspect: async (registration) => { + if (registration.pubkey !== owner) + throw new Error("foreign registration"); + return host; + }, + decode: async (_registration, report) => + report.decoded ?? decoded.get(report.id), + }; + const relay = { + fetchEvents: async (filter) => + events + .filter( + (e) => + (!filter.authors || filter.authors.includes(e.pubkey)) && + (filter.until === undefined || + e.created_at < filter.until || + (e.created_at === filter.until && + (!filter.before_id || e.id > filter.before_id))) && + Object.entries(filter) + .filter(([k]) => k.startsWith("#")) + .every(([k, values]) => + e.tags.some( + (t) => t[0] === k.slice(1) && values.includes(t[1]), + ), + ), + ) + .sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id)) + .slice(0, filter.limit), + publishEvent: async (event) => { + writes.push(event); + if (!events.some((stored) => stored.id === event.id)) events.push(event); + }, + }; + const run = (overrides = {}) => + reconcileHost({ + owner, + relay, + bridge, + journal, + active: () => active, + now: () => now, + ...overrides, + }); + return { + run, + journal, + storage, + decoded, + relay, + bridge, + writes, + events, + payload, + setNow: (t) => { + now = t; + }, + stop: () => { + active = false; + }, + }; +} diff --git a/desktop/src/features/hosts/moveSelection.test.mjs b/desktop/src/features/hosts/moveSelection.test.mjs new file mode 100644 index 00000000000..79d4828ccae --- /dev/null +++ b/desktop/src/features/hosts/moveSelection.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { moveStatus, moveUnavailable } from "./moveSelection.ts"; + +const agent = "a".repeat(64); +const source = { + run: "b".repeat(32), + location: { host: "source", label: "Desktop source" }, +}; +const destination = { + host: "destination", + report: { accepts_start: true, provisioned: [{ agent }] }, +}; +test("Move selects exact run and disables same, offline, unknown and unprovisioned destinations", () => { + assert.match( + moveUnavailable(undefined, destination, agent, true), + /Select an active instance/, + ); + assert.match( + moveUnavailable({ ...source, run: "legacy" }, destination, agent, true), + /Select an active instance/, + ); + assert.match( + moveUnavailable(source, { ...destination, host: "source" }, agent, true), + /Already/, + ); + assert.match( + moveUnavailable(source, destination, agent, undefined), + /unknown/, + ); + assert.match(moveUnavailable(source, destination, agent, false), /offline/); + assert.match( + moveUnavailable(source, destination, "other-agent", true), + /Set up this same agent/, + ); + assert.equal(moveUnavailable(source, destination, agent, true), undefined); +}); +test("Every supported lifecycle observation has truthful recovery copy", () => { + assert.match(moveStatus("stopping"), /destination has not started/); + assert.match(moveStatus("stop_unconfirmed"), /blocked; no destination Start/); + assert.match( + moveStatus("stopped_waiting_destination"), + /Source confirmed stopped.*will not restart/, + ); + assert.match(moveStatus("starting"), /waiting for its outcome/); + assert.match( + moveStatus("stopped_start_rejected"), + /Source confirmed stopped; destination rejected.*new Start attempt/, + ); + assert.match( + moveStatus("destination_spawned"), + /spawned.*Readiness is not yet confirmed/, + ); + assert.match( + moveStatus("stopped_start_unknown"), + /Retry the saved Start, never a replacement/, + ); +}); diff --git a/desktop/src/features/hosts/moveSelection.ts b/desktop/src/features/hosts/moveSelection.ts new file mode 100644 index 00000000000..8f6731fe600 --- /dev/null +++ b/desktop/src/features/hosts/moveSelection.ts @@ -0,0 +1,53 @@ +import type { HostRow } from "./registration"; +import type { PresenceRun } from "@/features/presence/runPresence"; +import { startUnavailable } from "./startSelection"; + +export type MoveProgress = { + operation: string; + agent: string; + source_host: string; + source_run: string; + destination_host: string; + destination_run: string; + status: string; + error?: string; +}; + +/** Presence chooses an exact generation, never proves its termination. */ +export function moveUnavailable( + source: PresenceRun | undefined, + destination: HostRow, + agent: string, + online: boolean | undefined, + destinationActive = false, +) { + if (!source?.location || !/^[a-f0-9]{32}$/.test(source.run)) + return "Select an active instance with a known host and run"; + if (source.location.host === destination.host) return "Already on this host"; + if (destinationActive) + return "Agent already has an active instance on this host"; + return startUnavailable(destination, agent, online); +} + +export function moveStatus(status: string): string { + switch (status) { + case "stopping": + return "Stopping only the selected instance; destination has not started."; + case "stop_unconfirmed": + return "Source termination unconfirmed. Move is blocked; no destination Start. Retry keeps the exact saved Stop."; + case "stopped_waiting_destination": + return "Source confirmed stopped. Waiting for destination setup or authorization; refresh and retry this Move. Source will not restart automatically."; + case "starting": + return "Source confirmed stopped. Destination Start queued; waiting for its outcome."; + case "stopped_start_rejected": + return "Source confirmed stopped; destination rejected Start. Fix destination setup, then create a new Start attempt on that host."; + case "destination_spawned": + return "Source confirmed stopped; destination process spawned in a fresh session. Readiness is not yet confirmed."; + case "destination_listening": + return "Source confirmed stopped; destination harness listening. Readiness is not yet confirmed."; + case "destination_ready": + return "Source confirmed stopped; destination reported ready in a fresh session."; + default: + return "Source confirmed stopped; destination outcome unknown. Retry the saved Start, never a replacement or automatic source restart."; + } +} diff --git a/desktop/src/features/hosts/pendingPublication.test.mjs b/desktop/src/features/hosts/pendingPublication.test.mjs new file mode 100644 index 00000000000..56542d42391 --- /dev/null +++ b/desktop/src/features/hosts/pendingPublication.test.mjs @@ -0,0 +1,319 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fixture, memoryStorage } from "./hostTestFixtures.mjs"; +import { + canonicalHostEvent, + createHostPublicationJournal, +} from "./pendingPublication.ts"; + +async function uncertain(f, stage) { + const publish = f.relay.publishEvent; + let held; + f.relay.publishEvent = async (event) => { + if ( + event.tags.some((t) => t[1] === (stage === "report" ? "profile" : stage)) + ) { + held = event; + throw new Error("uncertain"); + } + return publish(event); + }; + await assert.rejects(f.run(), /uncertain/); + f.relay.publishEvent = publish; + return held; +} + +for (const stage of ["registration", "report"]) { + test(`${stage} journal stores only canonical ciphertext before first send and survives repeated rejection`, async () => { + const f = fixture(); + f.payload.name = "PRIVATE-HOSTNAME"; + const held = await uncertain(f, stage); + const raw = [...f.storage.entries.values()][0]; + assert.ok(!raw.includes("PRIVATE-HOSTNAME")); + assert.ok(!raw.includes("decoded")); + assert.ok(!raw.includes("runtimes")); + assert.equal(Object.keys(held).length, 7); + f.relay.publishEvent = async (event) => { + assert.deepEqual(event, held); + assert.equal([...f.storage.entries.values()][0], raw); + throw new Error("rejected again"); + }; + f.bridge.registration = f.bridge.report = async () => + assert.fail("must not rebuild"); + await assert.rejects(f.run(), /rejected again/); + assert.equal([...f.storage.entries.values()][0], raw); + }); + + for (const mode of ["failed", "capped", "unreadable"]) { + test(`pending ${stage} recovery waits for all ${mode} history before any send`, async () => { + const f = fixture(); + await uncertain(f, stage); + const raw = [...f.storage.entries.values()][0]; + const fetch = f.relay.fetchEvents; + f.relay.fetchEvents = async (filter) => { + if (filter["#l"][0] === "report") { + if (mode === "failed") throw new Error("history failed"); + const report = await f.bridge.report(f.journal.load().registration); + if (mode === "capped") return Array(1000).fill(report); + return [{ ...report, tags: [] }]; + } + return fetch(filter); + }; + f.relay.publishEvent = async () => + assert.fail("send after unsafe history"); + await assert.rejects( + f.run(), + /history failed|cursor did not advance|Cannot establish/, + ); + assert.equal([...f.storage.entries.values()][0], raw); + }); + } + + test(`switch during pending ${stage} native validation fences recovery and preserves journal`, async () => { + const f = fixture(); + await uncertain(f, stage); + const raw = [...f.storage.entries.values()][0]; + const operation = stage === "registration" ? "inspect" : "decode"; + const native = f.bridge[operation]; + f.bridge[operation] = async (...args) => { + const result = await native(...args); + f.stop(); + return result; + }; + f.relay.publishEvent = async () => assert.fail("stale send"); + await assert.rejects(f.run(), /cancelled/); + assert.equal([...f.storage.entries.values()][0], raw); + }); + + test(`crash after saving ${stage} but before send recovers solely from persisted signed event`, async () => { + const f = fixture(); + const registration = canonicalHostEvent(await f.bridge.registration()); + const report = + stage === "report" + ? canonicalHostEvent(await f.bridge.report(registration)) + : undefined; + if (report) f.events.push(registration); + f.journal.save({ v: 1, registration, ...(report ? { report } : {}) }); + const restarted = createHostPublicationJournal( + "wss://fixture.invalid", + "a".repeat(64), + f.storage, + ); + f.bridge.registration = async () => + assert.fail("rebuilding pending registration"); + if (report) + f.bridge.report = async () => assert.fail("rebuilding pending report"); + await f.run({ journal: restarted }); + assert.deepEqual(f.writes[0], report ?? registration); + assert.equal(restarted.load(), undefined); + }); +} + +for (const raw of [ + "", + "{", + "null", + "[]", + '{"v":2}', + '{"v":1,"registration":{}}', +]) { + test(`malformed journal ${JSON.stringify(raw)} is not discarded or treated as absence`, async () => { + const f = fixture(); + await uncertain(f, "registration"); + const key = [...f.storage.entries.keys()][0]; + f.storage.entries.set(key, raw); + f.relay.publishEvent = async () => assert.fail("must fail closed"); + await assert.rejects(f.run(), /pending publication/); + assert.equal(f.storage.entries.get(key), raw); + }); +} + +for (const field of ["content", "sig", "pubkey"]) { + for (const stage of ["registration", "report"]) { + test(`native validation rejects shape-valid tampered ${stage} ${field} without sending or clearing`, async () => { + const f = fixture(); + const held = await uncertain(f, stage); + const [key, raw] = [...f.storage.entries][0]; + const pending = JSON.parse(raw); + pending[stage][field] = + field === "content" + ? "tampered-ciphertext" + : "e".repeat(field === "sig" ? 128 : 64); + f.storage.entries.set(key, JSON.stringify(pending)); + // Simulated native cryptographic boundary: native receives and rejects + // the changed signed fields. JS shape validation must not replace it. + let validations = 0; + const operation = stage === "registration" ? "inspect" : "decode"; + const original = f.bridge[operation]; + f.bridge[operation] = async (...args) => { + validations++; + if (args.at(-1)[field] !== held[field]) + throw new Error("native verification failed"); + return original(...args); + }; + f.relay.publishEvent = async () => assert.fail("tampered send"); + await assert.rejects(f.run(), /native verification failed/); + assert.equal(validations, 1); + assert.equal(f.storage.entries.has(key), true); + }); + } +} + +test("foreign local host and missing report binding fail closed on recovery", async () => { + const f = fixture(); + await uncertain(f, "report"); + f.bridge.local = async () => ({ host: "c".repeat(64), report: f.payload }); + await assert.rejects(f.run(), /different local host/); + f.bridge.local = async () => ({ host: "b".repeat(64), report: f.payload }); + f.events.length = 0; + await assert.rejects(f.run(), /registration is missing/); + assert.ok(f.journal.load()); +}); + +test("identity/community scopes are independent and switching back restores the exact pending event", async () => { + const f = fixture(); + const held = await uncertain(f, "registration"); + const otherOwner = createHostPublicationJournal( + "wss://fixture.invalid", + "c".repeat(64), + f.storage, + ); + const otherRelay = createHostPublicationJournal( + "wss://other.invalid", + "a".repeat(64), + f.storage, + ); + assert.equal(otherOwner.load(), undefined); + assert.equal(otherRelay.load(), undefined); + const registration = await f.bridge.registration(); + otherRelay.save({ v: 1, registration }); + otherOwner.save({ + v: 1, + registration: { ...registration, pubkey: "c".repeat(64) }, + }); + await f.run({ + journal: createHostPublicationJournal( + "wss://fixture.invalid", + "a".repeat(64), + f.storage, + ), + }); + assert.deepEqual(f.writes[0], held); + assert.ok(otherRelay.load()); + assert.ok(otherOwner.load()); + // A copied foreign slot is not authority: the expected-owner native bridge + // rejects the registration, even though its stored shape is valid. + await assert.rejects(f.run({ journal: otherOwner }), /foreign registration/); +}); + +for (const failure of ["read", "write", "silent-write"]) { + test(`${failure} storage failure blocks first send`, async () => { + const f = fixture(); + const storage = memoryStorage(); + if (failure === "read") + storage.getItem = () => { + throw new Error("private diagnostic"); + }; + else + storage.setItem = () => { + if (failure === "write") throw new Error("private diagnostic"); + }; + const journal = createHostPublicationJournal( + "wss://fixture.invalid", + "a".repeat(64), + storage, + ); + await assert.rejects( + f.run({ journal }), + (e) => + /pending publication/.test(e.message) && !e.message.includes("private"), + ); + assert.equal(f.writes.length, 0); + }); +} + +test("clear failure after accepted send preserves pending state for read-based recovery", async () => { + const f = fixture(); + const remove = f.storage.removeItem; + f.storage.removeItem = () => { + throw new Error("private diagnostic"); + }; + await assert.rejects(f.run(), /pending publication/); + assert.equal(f.writes.length, 1); + assert.ok(f.journal.load()); + f.storage.removeItem = remove; + await f.run(); + assert.equal(f.writes.length, 2); + assert.equal(f.journal.load(), undefined); +}); + +test("unconfirmed durable profile retries the exact event even after many lease windows", async () => { + const f = fixture(); + const held = await uncertain(f, "report"); + f.setNow(10000); + const snapshot = await f.run(); + assert.equal(f.writes.length, 2); + assert.deepEqual(f.writes[1], held); + assert.equal(snapshot.rows[0].event.id, held.id); + assert.equal(f.journal.load(), undefined); +}); + +test("pending older report cannot replace newer relay-confirmed capabilities", async () => { + const f = fixture(); + const held = await uncertain(f, "report"); + f.setNow(110); + f.payload.name = "newer"; + const newer = await f.bridge.report(f.events[0]); + f.events.push(newer); + const snapshot = await f.run(); + assert.deepEqual(f.writes.at(-1), held); + assert.equal(snapshot.rows[0].event.id, newer.id); + assert.equal(snapshot.rows[0].report.name, "newer"); +}); + +for (const [name, mutate] of [ + [ + "extra plaintext field", + (p) => { + p.registration.decoded = { name: "PRIVATE-HOSTNAME" }; + }, + ], + [ + "wrong timestamp", + (p) => { + p.registration.created_at = -1; + }, + ], + [ + "malformed tag", + (p) => { + p.registration.tags = [null]; + }, + ], + [ + "wrong signature shape", + (p) => { + p.registration.sig = "bad"; + }, + ], + [ + "oversized journal", + (p) => { + p.registration.content = "x".repeat(1024 * 1024); + }, + ], +]) { + test(`${name} in stored journal fails closed before native recovery`, async () => { + const f = fixture(); + await uncertain(f, "registration"); + const [key, raw] = [...f.storage.entries][0]; + const pending = JSON.parse(raw); + mutate(pending); + f.storage.entries.set(key, JSON.stringify(pending)); + f.bridge.inspect = async () => + assert.fail("malformed journal passed to native"); + await assert.rejects(f.run(), /pending publication/); + assert.equal(f.writes.length, 0); + assert.ok(f.storage.entries.has(key)); + }); +} diff --git a/desktop/src/features/hosts/pendingPublication.ts b/desktop/src/features/hosts/pendingPublication.ts new file mode 100644 index 00000000000..6256c11500e --- /dev/null +++ b/desktop/src/features/hosts/pendingPublication.ts @@ -0,0 +1,123 @@ +import type { RelayEvent } from "@/shared/api/types"; + +/** Only canonical signed, encrypted events; never decoded host metadata or keys. */ +export type PendingHostPublication = { + v: 1; + registration: RelayEvent; + report?: RelayEvent; +}; +export type HostPublicationJournal = { + load(): PendingHostPublication | undefined; + save(pending: PendingHostPublication): void; + clear(): void; +}; + +const FIELDS = ["id", "pubkey", "kind", "content", "created_at", "tags", "sig"]; +const HEX = /^[0-9a-f]{64}$/; +const MAX_BYTES = 1024 * 1024; +const invalid = () => + new Error("Host pending publication is unavailable or invalid"); + +/** Strip transport/native adornments before saving or sending a signed event. */ +export function canonicalHostEvent(event: RelayEvent): RelayEvent { + return { + id: event.id, + pubkey: event.pubkey, + kind: event.kind, + content: event.content, + created_at: event.created_at, + tags: event.tags.map((tag) => [...tag]), + sig: event.sig, + }; +} + +function eventShape(value: unknown): value is RelayEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const e = value as RelayEvent; + return ( + Object.keys(e).length === FIELDS.length && + Object.keys(e).every((key) => FIELDS.includes(key)) && + typeof e.id === "string" && + HEX.test(e.id) && + typeof e.pubkey === "string" && + HEX.test(e.pubkey) && + e.kind === 50000 && + typeof e.content === "string" && + e.content.length > 0 && + Number.isSafeInteger(e.created_at) && + e.created_at >= 0 && + typeof e.sig === "string" && + /^[0-9a-f]{128}$/.test(e.sig) && + Array.isArray(e.tags) && + e.tags.every( + (tag) => Array.isArray(tag) && tag.every((s) => typeof s === "string"), + ) + ); +} + +function parse(raw: string): PendingHostPublication { + if (raw.length * 2 > MAX_BYTES) throw invalid(); + const value = JSON.parse(raw); + if ( + value?.v !== 1 || + Object.keys(value).some( + (key) => !["v", "registration", "report"].includes(key), + ) || + !eventShape(value.registration) || + ("report" in value && !eventShape(value.report)) + ) + throw invalid(); + return value; +} + +/** + * Load-bearing localStorage journal, scoped by exact relay URL and signer. + * Storage/corruption errors fail closed, never become absence. Unlike caches it + * must not be evicted or fall back to memory on quota failure. Native inspection + * and decryption remain authoritative: shape checks are NOT signature checks. + */ +export function createHostPublicationJournal( + relay: string, + owner: string, + storage?: Pick, +): HostPublicationJournal { + const getStorage = () => storage ?? localStorage; + const key = `buzz-host-pending.v1:${JSON.stringify([relay, owner])}`; + return { + load() { + try { + const raw = getStorage().getItem(key); + return raw === null ? undefined : parse(raw); + } catch { + throw invalid(); + } + }, + save(pending) { + try { + // Refuse overwriting an unresolved or malformed attempt. + if (getStorage().getItem(key) !== null) throw invalid(); + const raw = JSON.stringify({ + v: 1, + registration: canonicalHostEvent(pending.registration), + ...(pending.report + ? { report: canonicalHostEvent(pending.report) } + : {}), + }); + parse(raw); + getStorage().setItem(key, raw); + if (getStorage().getItem(key) !== raw) throw invalid(); + } catch { + // Never include diagnostics which could echo payloads or private paths. + throw invalid(); + } + }, + clear() { + try { + getStorage().removeItem(key); + if (getStorage().getItem(key) !== null) throw invalid(); + } catch { + throw invalid(); + } + }, + }; +} diff --git a/desktop/src/features/hosts/projection.test.mjs b/desktop/src/features/hosts/projection.test.mjs new file mode 100644 index 00000000000..aad4fef3eef --- /dev/null +++ b/desktop/src/features/hosts/projection.test.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fixture } from "./hostTestFixtures.mjs"; +import { validateHostReport } from "./reportValidation.ts"; + +for (const label of ["registration", "report"]) { + test(`newer invalid ${label} does not erase valid remembered state`, async () => { + const f = fixture(); + const first = await f.run(); + f.writes.length = 0; + const valid = f.events[label === "registration" ? 0 : 1]; + f.events.push({ + ...valid, + id: "f".repeat(64), + created_at: 101, + invalid: true, + }); + const operation = label === "registration" ? "inspect" : "decode"; + const original = f.bridge[operation]; + f.bridge[operation] = async (...args) => { + if (args.at(-1).invalid) throw new Error("invalid encrypted record"); + return original(...args); + }; + f.setNow(110); + const result = await f.run(); + assert.equal(result.rows[0].registration.id, first.rows[0].registration.id); + assert.equal(result.rows[0].event.id, first.rows[0].event.id); + assert.equal(f.writes.length, 0); + }); +} + +test("latest valid registration uses timestamp DESC, id ASC independent of arrival", async () => { + const f = fixture(); + const registration = await f.bridge.registration(); + const low = { ...registration, id: "1".repeat(64), created_at: 101 }; + const high = { ...registration, id: "2".repeat(64), created_at: 101 }; + f.events.push(high, registration, low); + f.events.push(await f.bridge.report(low)); + const result = await f.run(); + assert.equal(result.rows[0].registration.id, low.id); + assert.equal(f.writes.length, 0); +}); + +test("latest valid report uses timestamp DESC, id ASC and keeps expired capabilities", async () => { + const f = fixture(); + const registration = await f.bridge.registration(); + const report = await f.bridge.report(registration); + const low = { ...report, id: "1".repeat(64), created_at: 101 }; + const high = { ...report, id: "2".repeat(64), created_at: 101 }; + f.events.push(registration, high, report, low); + let result = await f.run(); + assert.equal(result.rows[0].event.id, low.id); + // A remembered nonlocal host remains durable when its lease expires. + f.bridge.local = async () => ({ host: "c".repeat(64), report: f.payload }); + const other = { + ...registration, + id: "3".repeat(64), + tags: registration.tags.map((t) => + t[0] === "x" ? ["x", "c".repeat(64)] : t, + ), + }; + f.events.push(other); + f.bridge.inspect = async (event) => event.tags.find((t) => t[0] === "x")[1]; + // Keep the local fixture's renewed lease fresh so only remembered state is tested. + const otherReport = { + ...report, + pubkey: "c".repeat(64), + id: "4".repeat(64), + created_at: 300, + tags: report.tags.map((t) => + t[0] === "x" + ? ["x", "c".repeat(64)] + : t[0] === "e" + ? ["e", other.id] + : t[0] === "valid_until" + ? ["valid_until", "480"] + : t, + ), + }; + f.events.push(otherReport); + f.setNow(300); + result = await f.run(); + assert.equal( + result.rows.find((row) => row.host === "b".repeat(64)).event.id, + low.id, + ); + assert.equal(f.writes.length, 0); +}); + +test("incomplete report history blocks all event construction/publication", async () => { + const f = fixture(); + const registration = await f.bridge.registration(); + f.events.push( + registration, + ...Array(1000).fill(await f.bridge.report(registration)), + ); + f.bridge.registration = f.bridge.report = async () => + assert.fail("construction after incomplete history"); + await assert.rejects(f.run(), /cursor did not advance/); + assert.equal(f.writes.length, 0); +}); + +test("unreadable report history is not mistaken for no report", async () => { + const f = fixture(); + await f.run(); + f.writes.length = 0; + f.bridge.decode = async () => { + throw new Error("decode failed"); + }; + await assert.rejects(f.run(), /Cannot establish host capabilities/); + assert.equal(f.writes.length, 0); +}); + +const invalidPayloads = [ + [ + "unknown availability", + (r) => { + r.runtimes[0].availability = "ready"; + }, + ], + [ + "unknown auth status", + (r) => { + r.runtimes[0].auth_status = "ready"; + }, + ], + [ + "duplicate runtime", + (r) => { + r.runtimes.push({ ...r.runtimes[0] }); + }, + ], + [ + "unknown field", + (r) => { + r.diagnostic = "not allowed"; + }, + ], + [ + "runtime extra field", + (r) => { + r.runtimes[0].path = "not allowed"; + }, + ], + [ + "oversized text bytes", + (r) => { + r.name = "é".repeat(129); + }, + ], + [ + "control text", + (r) => { + r.name = "bad\u0085text"; + }, + ], + [ + "empty text", + (r) => { + r.name = ""; + }, + ], + [ + "unsupported version", + (r) => { + r.v = 4; + }, + ], + [ + "start enabled", + (r) => { + r.accepts_start = true; + }, + ], + [ + "oversized catalog", + (r) => { + r.runtimes = Array(129).fill(r.runtimes[0]); + }, + ], + [ + "missing runtime id", + (r) => { + delete r.runtimes[0].id; + }, + ], +]; +for (const [name, mutate] of invalidPayloads) { + test(`${name} is skipped in history and cannot be published from discovery`, async () => { + const f = fixture(); + await f.run(); + f.writes.length = 0; + const invalid = structuredClone(f.events[1]); + invalid.decoded = structuredClone(f.decoded.get(invalid.id)); + invalid.id = "f".repeat(64); + invalid.created_at++; + mutate(invalid.decoded); + assert.throws(() => validateHostReport(invalid.decoded), /Invalid host/); + f.events.push(invalid); + await f.run(); + assert.equal(f.writes.length, 0); + mutate(f.payload); + await assert.rejects(f.run(), /Invalid host/); + assert.equal(f.writes.length, 0); + }); +} + +test("all real native runtime statuses and empty catalogs are supported", () => { + const f = fixture(); + for (const availability of [ + "available", + "adapter_missing", + "adapter_outdated", + "cli_missing", + "not_installed", + ]) { + for (const auth_status of [ + "logged_in", + "logged_out", + "config_invalid", + "not_applicable", + "unknown", + ]) { + f.payload.runtimes[0] = { + id: "one", + label: "One", + availability, + auth_status, + }; + validateHostReport(f.payload); + } + } + f.payload.runtimes = []; + validateHostReport(f.payload); +}); + +test("a newer duplicate registration reuses unchanged capabilities bound to the older registration", async () => { + const f = fixture(); + const first = await f.run(); + f.writes.length = 0; + const newer = { ...f.events[0], id: "f".repeat(64), created_at: 101 }; + f.events.push(newer); + f.setNow(110); + const result = await f.run(); + assert.equal(result.rows[0].registration.id, newer.id); + assert.equal(result.rows[0].event.id, first.rows[0].event.id); + assert.equal(f.writes.length, 0); +}); + +test("a report with unknown registration cannot supersede a verified host report", async () => { + const f = fixture(); + const first = await f.run(); + f.writes.length = 0; + const invalid = { + ...f.events[1], + id: "f".repeat(64), + created_at: 101, + tags: f.events[1].tags.map((tag) => + tag[0] === "e" ? ["e", "unknown"] : tag, + ), + }; + f.events.push(invalid); + const result = await f.run(); + assert.equal(result.rows[0].event.id, first.rows[0].event.id); + assert.equal(f.writes.length, 0); +}); diff --git a/desktop/src/features/hosts/registration.test.mjs b/desktop/src/features/hosts/registration.test.mjs new file mode 100644 index 00000000000..60c5bc6458b --- /dev/null +++ b/desktop/src/features/hosts/registration.test.mjs @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { needsReport, isFresh } from "./registration.ts"; +import { fixture } from "./hostTestFixtures.mjs"; + +test("register once; an unchanged current host does not append on restart/reconnect", async () => { + const f = fixture(); + const first = await f.run(); + assert.equal(f.writes.length, 2); + assert.equal(first.rows.length, 1); + const second = await f.run(); + assert.equal(f.writes.length, 2); + assert.equal(second.rows[0].registration.id, first.rows[0].registration.id); +}); + +test("unchanged profiles do not renew across multiple lease windows", async () => { + const f = fixture(); + await f.run(); + f.setNow(220); + await f.run(); + assert.equal(f.writes.length, 2); + f.setNow(240); + await f.run(); + assert.equal(f.writes.length, 2); + f.setNow(10000); + await f.run(); + assert.equal(f.writes.length, 2); +}); + +test("capability changes publish a report without re-registering", async () => { + const f = fixture(); + await f.run(); + f.payload.runtimes[0].availability = "cli_missing"; + f.setNow(110); + await f.run(); + assert.equal(f.writes.length, 3); +}); + +test("ciphertext, property order, and runtime ordering are not change detectors", async () => { + const f = fixture(); + const state = await f.run(); + const current = { + accepts_start: false, + ...f.payload, + runtimes: [...f.payload.runtimes].reverse(), + }; + assert.equal(needsReport(state.rows[0], current, 101), false); +}); + +test("failed relay read does not blindly append", async () => { + const f = fixture(); + f.relay.fetchEvents = async () => { + throw new Error("offline"); + }; + await assert.rejects(f.run(), /offline/); + assert.equal(f.writes.length, 0); +}); + +test("failed registration acknowledgement prevents reports and success", async () => { + const f = fixture(); + f.relay.publishEvent = async () => { + throw new Error("rejected"); + }; + await assert.rejects(f.run(), /rejected/); + assert.equal(f.events.length, 0); +}); + +test("accepted registration is reused after failed report acknowledgement", async () => { + const f = fixture(); + const publish = f.relay.publishEvent; + f.relay.publishEvent = async (event) => { + if (event.tags.some((t) => t[0] === "l" && t[1] === "profile")) + throw new Error("report rejected"); + await publish(event); + }; + await assert.rejects(f.run(), /report rejected/); + f.relay.publishEvent = publish; + await f.run(); + assert.equal(f.writes.length, 2); +}); + +test("identity/relay switch fences delayed native results from publication", async () => { + const f = fixture(); + const local = f.bridge.local; + f.bridge.local = async () => { + f.stop(); + return local(); + }; + await assert.rejects(f.run(), /cancelled/); + assert.equal(f.writes.length, 0); +}); + +test("malformed or foreign registration fails closed", async () => { + const f = fixture(); + await f.run(); + f.bridge.inspect = async () => { + throw new Error("foreign registration"); + }; + await assert.rejects(f.run(), /Cannot establish local host registration/); + assert.equal(f.writes.length, 2); +}); + +test("legacy report freshness remains bounded", async () => { + const f = fixture({ legacy: true }); + const state = await f.run(); + const event = state.rows[0].event; + assert.equal(isFresh(event, 279), true); + assert.equal(isFresh(event, 280), false); + assert.equal(isFresh(undefined, 100), false); +}); + +test("fresh process reads correct relay state and performs zero publication calls", async () => { + const original = fixture(); + await original.run(); + const restarted = fixture(); + restarted.events.push(...structuredClone(original.events)); + for (const [id, report] of original.decoded) + restarted.decoded.set(id, report); + // No in-memory publication cache from the original process survives. + restarted.bridge.registration = async () => { + throw new Error("unexpected registration"); + }; + restarted.bridge.report = async () => { + throw new Error("unexpected report"); + }; + for (const trigger of ["restart", "reconnect", "manual refresh"]) { + restarted.setNow(110); + const result = await restarted.run(); + assert.equal(result.rows.length, 1, trigger); + assert.equal(restarted.writes.length, 0, trigger); + } +}); + +test("failed report read never appends to an existing registration", async () => { + const f = fixture(); + await f.run(); + f.writes.length = 0; + const fetch = f.relay.fetchEvents; + f.relay.fetchEvents = async (filter) => { + if (filter["#l"][0] === "report") throw new Error("read failed"); + return fetch(filter); + }; + f.setNow(1000); + await assert.rejects(f.run(), /read failed/); + assert.equal(f.writes.length, 0); +}); + +test("a same-second capability change waits instead of creating ambiguous heads", async () => { + const f = fixture(); + await f.run(); + f.payload.name = "changed"; + await assert.rejects(f.run(), /current second/); + assert.equal(f.writes.length, 2); + f.setNow(101); + await f.run(); + assert.equal(f.writes.length, 3); + await f.run(); + assert.equal(f.writes.length, 3); +}); + +test("a future profile does not cause needless publication", async () => { + const f = fixture(); + const state = await f.run(); + state.rows[0].event.created_at = 150; + assert.equal(needsReport(state.rows[0], f.payload, 100), false); +}); + +test("incomplete registration history fails closed without publishing", async () => { + const f = fixture(); + const event = await f.bridge.registration(); + f.events.push(...Array(1000).fill(event)); + await assert.rejects(f.run(), /cursor did not advance/); + assert.equal(f.writes.length, 0); +}); + +test("failed remembered-host read prevents even a new local registration", async () => { + const f = fixture(); + await f.run(); + f.writes.length = 0; + f.bridge.local = async () => ({ host: "c".repeat(64), report: f.payload }); + let constructions = 0; + f.bridge.registration = async () => { + constructions++; + throw new Error("must not construct"); + }; + const fetch = f.relay.fetchEvents; + f.relay.fetchEvents = async (filter) => { + if (filter["#l"][0] === "report") throw new Error("history unavailable"); + return fetch(filter); + }; + await assert.rejects(f.run(), /history unavailable/); + assert.equal(constructions, 0); + assert.equal(f.writes.length, 0); +}); + +for (const label of ["registration", "profile"]) { + test(`accepted ${label} with lost ACK is reused after reconnect`, async () => { + const f = fixture(); + const publish = f.relay.publishEvent; + f.relay.publishEvent = async (event) => { + await publish(event); + if (event.tags.some((t) => t[0] === "l" && t[1] === label)) + throw new Error("ACK lost"); + }; + await assert.rejects(f.run(), /ACK lost/); + f.relay.publishEvent = publish; + const result = await f.run(); + assert.equal(result.rows.length, 1); + assert.equal(f.writes.length, 2); + assert.equal(f.events.length, 2); + }); +} + +for (const operation of ["registration", "report", "inspect", "decode"]) { + test(`identity switch during ${operation} prevents later publication`, async () => { + const f = fixture(); + await f.run(); + f.writes.length = 0; + // Force the native stage under test to be reached. + if (operation === "registration") f.events.length = 0; + if (operation === "report") { + f.setNow(240); + f.payload.name = "Changed"; + } + const original = f.bridge[operation]; + f.bridge[operation] = async (...args) => { + const result = await original(...args); + f.stop(); + return result; + }; + await assert.rejects(f.run(), /cancelled/); + assert.equal(f.writes.length, 0); + }); +} + +test("legacy report is upgraded once to a durable profile", async () => { + const legacy = fixture({ legacy: true }); + await legacy.run(); + const current = fixture(); + current.events.push(...structuredClone(legacy.events)); + for (const [id, report] of legacy.decoded) current.decoded.set(id, report); + current.setNow(300); + await current.run(); + assert.equal(current.writes.length, 1); + assert.equal(current.writes[0].tags.find((t) => t[0] === "l")[1], "profile"); + current.setNow(10000); + await current.run(); + assert.equal(current.writes.length, 1); +}); diff --git a/desktop/src/features/hosts/registration.ts b/desktop/src/features/hosts/registration.ts new file mode 100644 index 00000000000..34d81ba9091 --- /dev/null +++ b/desktop/src/features/hosts/registration.ts @@ -0,0 +1,311 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { fetchHostHistory, type HostHistoryFilter } from "./history"; +import { + canonicalHostEvent, + type HostPublicationJournal, +} from "./pendingPublication"; +import { validateHostReport } from "./reportValidation"; + +export const HOST_KIND = 50000; +export const HOST_NAMESPACE = "buzz.host.v1"; +export type HostReport = { + v: number; + name: string; + os: string; + arch: string; + launcher_version: string; + runtimes: { + id: string; + label: string; + availability: string; + auth_status: string; + }[]; + accepts_start: boolean; + provisioned?: { agent: string; runtime: string; revision: string }[]; +}; +export type LocalHost = { host: string; report: HostReport }; +export type HostRow = { + host: string; + registration: RelayEvent; + event?: RelayEvent; + report?: HostReport; +}; +export type HostSnapshot = { + rows: HostRow[]; + local?: LocalHost; + error?: string; + checking: boolean; +}; +export type HostBridge = { + local(): Promise; + registration(): Promise; + report(registration: RelayEvent): Promise; + inspect(registration: RelayEvent): Promise; + decode(registration: RelayEvent, report: RelayEvent): Promise; +}; +export type HostRelay = { + fetchEvents(filter: HostHistoryFilter): Promise; + publishEvent(event: RelayEvent): Promise; +}; +export const hostQueryKey = ( + relay: string | undefined, + owner: string | undefined, +) => ["hosts", relay, owner] as const; +export function tag(event: RelayEvent, name: string) { + return event.tags.find((t) => t[0] === name)?.[1]; +} +function sameSignedEvent(a: RelayEvent, b: RelayEvent) { + try { + return ( + JSON.stringify(canonicalHostEvent(a)) === + JSON.stringify(canonicalHostEvent(b)) + ); + } catch { + return false; + } +} +function newest(events: RelayEvent[]) { + return [...events].sort((a, b) => { + // Native inspection is authoritative; tolerate malformed transport objects + // here so one invalid record cannot prevent inspecting the valid candidates. + const timestamp = (e: RelayEvent) => + Number.isSafeInteger(e?.created_at) ? e.created_at : 0; + const id = (e: RelayEvent) => (typeof e?.id === "string" ? e.id : ""); + return ( + timestamp(b) - timestamp(a) || + (id(a) < id(b) ? -1 : id(a) > id(b) ? 1 : 0) + ); + }); +} +export function isFresh(event: RelayEvent | undefined, now: number) { + return ( + !!event && + event.created_at <= now + 30 && + Number(tag(event, "valid_until")) > now + ); +} +export function needsReport( + previous: HostRow | undefined, + current: HostReport, + _now: number, +) { + // Compare structured data, not randomized NIP-44 ciphertext or object key order. + const canonical = (r: HostReport) => + JSON.stringify([ + r.v, + r.name, + r.os, + r.arch, + r.launcher_version, + r.accepts_start, + [...(r.provisioned ?? [])] + .sort((a, b) => a.agent.localeCompare(b.agent)) + .map((c) => [c.agent, c.runtime, c.revision]), + [...r.runtimes] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((x) => [x.id, x.label, x.availability, x.auth_status]), + ]); + return ( + !previous?.report || + !previous.event || + tag(previous.event, "l") !== "profile" || + canonical(previous.report) !== canonical(current) + ); +} + +/** One serialized read-before-write pass. Failed reads never become empty state. */ +export async function reconcileHost(args: { + owner: string; + relay: HostRelay; + bridge: HostBridge; + journal: HostPublicationJournal; + active: () => boolean; + now: () => number; +}): Promise { + const { owner, relay, bridge, journal, now } = args; + const check = () => { + if (!args.active()) throw new Error("Host registration cancelled"); + }; + const filter = (label: string): HostHistoryFilter => ({ + kinds: [HOST_KIND], + "#p": [owner], + "#L": [HOST_NAMESPACE], + "#l": [label], + limit: 1000, + }); + check(); + const local = await bridge.local(); + check(); + validateHostReport(local.report); + const pending = journal.load(); + let pendingReport: HostReport | undefined; + if (pending) { + // Disk is untrusted. Do not use cached plaintext or trust a stored host ID. + if ((await bridge.inspect(pending.registration)) !== local.host) + throw new Error("Pending publication belongs to a different local host"); + check(); + if (pending.report) { + pendingReport = await bridge.decode(pending.registration, pending.report); + check(); + validateHostReport(pendingReport); + } + } + const registrations = await fetchHostHistory( + (page) => relay.fetchEvents(page), + { ...filter("registration"), authors: [owner] }, + check, + ); + check(); + const rows = new Map(); + const bindings = new Map< + string, + { host: string; registration: RelayEvent } + >(); + let unreadableRegistration = false; + for (const registration of newest(registrations)) { + let host: string; + try { + host = await bridge.inspect(registration); + } catch { + // A newer invalid record must not hide an older verified binding. + // IPC errors are not typed: absence is unsafe if no local binding survives. + unreadableRegistration = true; + check(); + continue; + } + check(); + bindings.set(registration.id, { host, registration }); + if (!rows.has(host)) rows.set(host, { host, registration }); + } + if (unreadableRegistration && !rows.has(local.host)) + throw new Error("Cannot establish local host registration from history"); + const pendingRegistrationKnown = + pending && bindings.has(pending.registration.id); + if (pending?.report && !pendingRegistrationKnown) + throw new Error("Pending report registration is missing from history"); + if (pending && !pending.report && !pendingRegistrationKnown) { + // Include the pending host in the read set, but do not return/promote this + // row until the exact registration has received an accepted ACK below. + bindings.set(pending.registration.id, { + host: local.host, + registration: pending.registration, + }); + if (!rows.has(local.host)) + rows.set(local.host, { + host: local.host, + registration: pending.registration, + }); + } + let pendingReportKnown = false; + // Complete every history read before constructing or publishing anything. + // In particular, an unreadable remembered host must not cause a new local + // registration to be appended on an otherwise failed reconciliation pass. + for (const row of rows.values()) { + const events = await fetchHostHistory( + (page) => relay.fetchEvents(page), + { + ...filter("report"), + "#l": ["report", "profile"], + authors: [row.host], + "#x": [row.host], + }, + check, + ); + check(); + if (row.host === local.host && pending?.report) + pendingReportKnown = events.some( + (event) => pending.report && sameSignedEvent(event, pending.report), + ); + for (const event of newest(events)) { + let report: HostReport; + try { + // Duplicate durable bindings can exist from older clients. Reuse the + // latest valid report for this owner+host even if it references an older + // binding; a newer registration must not force duplicate capabilities. + const binding = bindings.get(tag(event, "e") ?? ""); + if (!binding || binding.host !== row.host) + throw new Error("Unknown host report binding"); + report = await bridge.decode(binding.registration, event); + validateHostReport(report); + } catch { + check(); + continue; + } + check(); + row.event = event; + row.report = report; + break; + } + // An empty completed history is known absence; unreadable history is not. + if (events.length && !row.report) + throw new Error("Cannot establish host capabilities from history"); + } + let own = rows.get(local.host); + if (pending) { + const event = pending.report ?? pending.registration; + if (!(pending.report ? pendingReportKnown : pendingRegistrationKnown)) { + check(); + // The relay rejects expired reports, but an earlier ingest may still + // commit. Do not abandon the ID or mint a replacement: wait for history. + if ( + pending.report && + tag(pending.report, "l") === "report" && + Number(tag(pending.report, "valid_until")) <= now() + ) + throw new Error( + "Unconfirmed host report expired; waiting for relay history", + ); + // A completed primary read can overtake an old EVENT's commit. Re-send + // its EXACT signed fields, never a new randomized event. Relay event-ID + // uniqueness does the rest, regardless of which attempt commits first. + await relay.publishEvent(event); + check(); + } + journal.clear(); + if ( + pending.report && + pendingReport && + own && + (!own.event || + newest([own.event, pending.report])[0].id === pending.report.id) + ) { + own.event = pending.report; + own.report = pendingReport; + } + // Legacy reports are recovered by exact ID before upgrading to a durable profile. + } + if (!own) { + const registration = canonicalHostEvent(await bridge.registration()); + check(); + if ((await bridge.inspect(registration)) !== local.host) + throw new Error("Local host identity changed"); + check(); + journal.save({ v: 1, registration }); + check(); + await relay.publishEvent(registration); + check(); + journal.clear(); + own = { host: local.host, registration }; + rows.set(local.host, own); + } + if (needsReport(own, local.report, now())) { + // Kind 50000 uses (timestamp DESC, id ASC). Two changed reports in one + // second could select the old payload forever. Retry at the next tick + // rather than mint randomized ciphertext repeatedly at a tied timestamp. + if (own.event && own.event.created_at >= now()) + throw new Error("Host changed within the current second; retry shortly"); + const event = canonicalHostEvent(await bridge.report(own.registration)); + check(); + const report = await bridge.decode(own.registration, event); + check(); + validateHostReport(report); + journal.save({ v: 1, registration: own.registration, report: event }); + check(); + await relay.publishEvent(event); + check(); + journal.clear(); + own.event = event; + own.report = report; + } + return { rows: [...rows.values()], local, checking: false }; +} diff --git a/desktop/src/features/hosts/registrationLifecycle.test.mjs b/desktop/src/features/hosts/registrationLifecycle.test.mjs new file mode 100644 index 00000000000..49434fdb7a6 --- /dev/null +++ b/desktop/src/features/hosts/registrationLifecycle.test.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHostRegistrationLifecycle } from "./registrationLifecycle.ts"; +import { fixture } from "./hostTestFixtures.mjs"; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +function lifecycle(f, extra = {}) { + const snapshots = []; + const errors = []; + let connections = 0; + let disconnections = 0; + const controller = createHostRegistrationLifecycle({ + owner: "a".repeat(64), + bridge: f.bridge, + journal: f.journal, + now: () => 100, + connect: () => { + connections++; + return { + ...f.relay, + disconnect: () => { + disconnections++; + }, + }; + }, + checking: () => {}, + success: (s) => snapshots.push(s), + failure: (e) => errors.push(e), + ...extra, + }); + return { + ...controller, + snapshots, + errors, + counts: () => ({ connections, disconnections }), + }; +} + +test("focus/online/manual/timer bursts coalesce into a serialized read-before-write retry", async () => { + const f = fixture(); + const gate = deferred(); + const local = f.bridge.local; + let calls = 0; + f.bridge.local = async () => { + calls++; + await gate.promise; + return local(); + }; + const controller = lifecycle(f); + const first = controller.refresh(); + await flush(); + for (let i = 0; i < 10; i++) assert.equal(controller.refresh(), first); + assert.equal(calls, 1); + gate.resolve(); + await first; + assert.equal(calls, 2); + assert.equal(f.writes.length, 2); + assert.equal(controller.snapshots.length, 2); + assert.deepEqual(controller.counts(), { connections: 2, disconnections: 2 }); + await controller.stop(); +}); + +for (const operation of [ + "local", + "registration", + "report", + "inspect", + "decode", +]) { + test(`unmount during ${operation} fences publication, snapshots, errors and queued refresh`, async () => { + const f = fixture(); + const gate = deferred(); + const entered = deferred(); + const original = f.bridge[operation]; + f.bridge[operation] = async (...args) => { + entered.resolve(); + await gate.promise; + return original(...args); + }; + const controller = lifecycle(f); + const first = controller.refresh(); + await entered.promise; + const before = f.writes.length; + void controller.refresh(); + const stopped = controller.stop(); + gate.resolve(); + await Promise.all([first, stopped]); + await controller.refresh(); + assert.equal(f.writes.length, before); + assert.equal(controller.snapshots.length, 0); + assert.equal(controller.errors.length, 0); + assert.equal(controller.counts().connections, 1); + }); +} + +test("identity effect replacement waits for previous native work to drain", async () => { + const f = fixture(); + const gate = deferred(); + const local = f.bridge.local; + f.bridge.local = async () => { + await gate.promise; + return local(); + }; + const old = lifecycle(f); + void old.refresh(); + await flush(); + const next = lifecycle(fixture(), { after: old.stop() }); + const pending = next.refresh(); + await flush(); + assert.equal(next.counts().connections, 0); + gate.resolve(); + await pending; + assert.equal(f.writes.length, 0); + assert.equal(old.snapshots.length, 0); + assert.equal(next.snapshots.length, 1); + await next.stop(); +}); + +test("failed refresh preserves last confirmed snapshot and a later retry recovers without duplicates", async () => { + const f = fixture(); + const fetch = f.relay.fetchEvents; + let fail = false; + f.relay.fetchEvents = async (...args) => { + if (fail) throw new Error("offline"); + return fetch(...args); + }; + const controller = lifecycle(f); + await controller.refresh(); + fail = true; + await controller.refresh(); + assert.equal(controller.snapshots.length, 1); + assert.equal(controller.errors.length, 1); + fail = false; + await controller.refresh(); + assert.equal(controller.snapshots.length, 2); + assert.equal(f.writes.length, 2); + assert.equal(controller.counts().disconnections, 3); + await controller.stop(); +}); + +test("unmount during publication suppresses stale success and prevents the following report", async () => { + const f = fixture(); + const gate = deferred(); + const entered = deferred(); + const publish = f.relay.publishEvent; + f.relay.publishEvent = async (event) => { + await publish(event); + entered.resolve(); + await gate.promise; + }; + const controller = lifecycle(f); + const first = controller.refresh(); + await entered.promise; + const stopped = controller.stop(); + gate.resolve(); + await Promise.all([first, stopped]); + assert.equal(controller.snapshots.length, 0); + assert.equal(controller.errors.length, 0); + assert.equal(f.writes.length, 1); + f.relay.publishEvent = publish; + const next = lifecycle(f); + await next.refresh(); + assert.equal(f.writes.length, 2); + await next.stop(); +}); + +test("refresh at the completion microtask boundary is not lost", async () => { + const f = fixture(); + let successes = 0; + const controller = lifecycle(f, { + success: () => { + if (++successes === 1) + queueMicrotask(() => { + void controller.refresh(); + }); + }, + }); + await controller.refresh(); + assert.equal(successes, 2); + assert.equal(f.writes.length, 2); + await controller.stop(); +}); diff --git a/desktop/src/features/hosts/registrationLifecycle.ts b/desktop/src/features/hosts/registrationLifecycle.ts new file mode 100644 index 00000000000..b194fa0ec8a --- /dev/null +++ b/desktop/src/features/hosts/registrationLifecycle.ts @@ -0,0 +1,69 @@ +import type { HostPublicationJournal } from "./pendingPublication"; +import { + reconcileHost, + type HostBridge, + type HostRelay, + type HostSnapshot, +} from "./registration"; + +/** One effect's cancellable publisher. Refresh bursts coalesce, never overlap. */ +export function createHostRegistrationLifecycle(args: { + owner: string; + bridge: HostBridge; + journal: HostPublicationJournal; + connect: () => HostRelay & { disconnect(): void }; + now: () => number; + checking: () => void; + success: (snapshot: HostSnapshot) => void; + failure: (error: unknown) => void; + after?: Promise; +}) { + let active = true; + let pending = false; + let running: Promise | undefined; + let client: ReturnType | undefined; + const drain = async () => { + await args.after; + while (active && pending) { + pending = false; + args.checking(); + try { + client = args.connect(); + const snapshot = await reconcileHost({ + owner: args.owner, + relay: client, + bridge: args.bridge, + journal: args.journal, + active: () => active, + now: args.now, + }); + if (active) args.success(snapshot); + } catch (error) { + if (active) args.failure(error); + } finally { + client?.disconnect(); + client = undefined; + } + } + }; + const refresh = (): Promise => { + if (!active) return running ?? Promise.resolve(); + pending = true; + if (!running) + running = drain().finally(() => { + running = undefined; + // A refresh may arrive after drain returns but before this microtask. + if (active && pending) return refresh(); + }); + return running; + }; + return { + refresh, + stop(): Promise { + active = false; + pending = false; + client?.disconnect(); + return running ?? Promise.resolve(); + }, + }; +} diff --git a/desktop/src/features/hosts/reportValidation.ts b/desktop/src/features/hosts/reportValidation.ts new file mode 100644 index 00000000000..6e666073444 --- /dev/null +++ b/desktop/src/features/hosts/reportValidation.ts @@ -0,0 +1,102 @@ +import type { HostReport } from "./registration"; + +const availability = new Set([ + "available", + "adapter_missing", + "adapter_outdated", + "cli_missing", + "not_installed", +]); +const authStatus = new Set([ + "logged_in", + "logged_out", + "config_invalid", + "not_applicable", + "unknown", +]); +const encoder = new TextEncoder(); +function text(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + encoder.encode(value).length <= 256 && + !Array.from(value).some((c) => { + const code = c.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }) + ); +} +function object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function fields(value: Record, allowed: string[]) { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +/** Validate the decoded IPC payload before comparison, display or publication. */ +export function validateHostReport( + value: unknown, +): asserts value is HostReport { + if ( + !object(value) || + !fields(value, [ + "v", + "name", + "os", + "arch", + "launcher_version", + "runtimes", + "accepts_start", + "provisioned", + ]) || + (value.v !== 1 && value.v !== 2 && value.v !== 3) || + typeof value.accepts_start !== "boolean" || + (value.v !== 3 && + (value.accepts_start || + (Array.isArray(value.provisioned) && value.provisioned.length > 0))) || + ![value.name, value.os, value.arch, value.launcher_version].every(text) || + !Array.isArray(value.runtimes) || + value.runtimes.length > 128 + ) + throw new Error("Invalid host report payload"); + const ids = new Set(); + for (const runtime of value.runtimes) { + if ( + !object(runtime) || + !fields(runtime, ["id", "label", "availability", "auth_status"]) || + !text(runtime.id) || + !text(runtime.label) || + !text(runtime.availability) || + !availability.has(runtime.availability) || + !text(runtime.auth_status) || + !authStatus.has(runtime.auth_status) || + ids.has(runtime.id) + ) + throw new Error("Invalid host runtime payload"); + ids.add(runtime.id); + } + if (value.provisioned !== undefined) { + if (!Array.isArray(value.provisioned) || value.provisioned.length > 256) + throw new Error("Invalid host provisioning payload"); + const agents = new Set(); + for (const config of value.provisioned) { + if ( + !object(config) || + !fields(config, ["agent", "runtime", "revision"]) || + typeof config.agent !== "string" || + !/^[a-f0-9]{64}$/.test(config.agent) || + typeof config.revision !== "string" || + !/^[a-f0-9]{64}$/.test(config.revision) || + agents.has(config.agent) || + !value.runtimes.some( + (r) => + r.id === config.runtime && + r.availability === "available" && + ["logged_in", "not_applicable"].includes(r.auth_status), + ) + ) + throw new Error("Invalid host provisioning payload"); + agents.add(config.agent); + } + } +} diff --git a/desktop/src/features/hosts/startSelection.test.mjs b/desktop/src/features/hosts/startSelection.test.mjs new file mode 100644 index 00000000000..4e42e88a4b3 --- /dev/null +++ b/desktop/src/features/hosts/startSelection.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { startStatus, startUnavailable } from "./startSelection.ts"; +import { validateHostReport } from "./reportValidation.ts"; +import { needsReport } from "./registration.ts"; +const agent = "a".repeat(64); +const report = { + v: 3, + name: "private name", + os: "test", + arch: "test", + launcher_version: "test", + runtimes: [ + { + id: "goose", + label: "Goose", + availability: "available", + auth_status: "logged_in", + }, + ], + accepts_start: true, + provisioned: [{ agent, runtime: "goose", revision: "b".repeat(64) }], +}; +test("picker separates reachability, receiver compatibility, provisioning and workload", () => { + const row = { report }; + assert.match(startUnavailable(row, agent, undefined), /unknown/); + assert.match(startUnavailable(row, agent, false), /offline/); + assert.match( + startUnavailable( + { report: { ...report, accepts_start: false } }, + agent, + true, + ), + /compatible Start receiver/, + ); + assert.match( + startUnavailable(row, "c".repeat(64), true), + /Set up this same agent identity/, + ); + assert.equal(startUnavailable(row, agent, true), undefined); + assert.match( + startStatus("relay_accepted"), + /waiting for destination outcome/, + ); + assert.match(startStatus("spawned"), /readiness not yet confirmed/); + assert.match(startStatus("unknown"), /not launching a replacement/); +}); +test("v3 allows only compatible private provisioned refs, never secrets or legacy Start", () => { + validateHostReport(report); + for (const bad of [ + { ...report, v: 2 }, + { ...report, private_key: "never" }, + { ...report, provisioned: [{ ...report.provisioned[0], environment: {} }] }, + { ...report, provisioned: [{ ...report.provisioned[0], revision: "bad" }] }, + { ...report, provisioned: [report.provisioned[0], report.provisioned[0]] }, + { + ...report, + runtimes: [{ ...report.runtimes[0], auth_status: "unknown" }], + }, + ]) + assert.throws(() => validateHostReport(bad)); + const previous = { report, event: { tags: [["l", "profile"]] } }; + assert.equal(needsReport(previous, structuredClone(report), 100), false); + assert.equal( + needsReport(previous, { ...report, provisioned: [] }, 100), + true, + ); +}); diff --git a/desktop/src/features/hosts/startSelection.ts b/desktop/src/features/hosts/startSelection.ts new file mode 100644 index 00000000000..b5f67fc5c8a --- /dev/null +++ b/desktop/src/features/hosts/startSelection.ts @@ -0,0 +1,36 @@ +import type { HostRow } from "./registration"; + +/** Cached availability is only a picker hint. Native rechecks every fact at spawn. */ +export function startUnavailable( + row: HostRow, + agent: string, + online: boolean | undefined, +): string | undefined { + if (online === undefined) return "Destination availability unknown"; + if (!online) return "Destination is offline"; + if (!row.report?.accepts_start) + return "Destination has no compatible Start receiver"; + if (!agent) return "Choose an agent"; + if (!row.report.provisioned?.some((config) => config.agent === agent)) + return "Set up this same agent identity and compatible configuration on the destination first"; + return undefined; +} + +export function startStatus(status: string): string { + switch (status) { + case "queued": + return "Saved locally; waiting for relay"; + case "relay_accepted": + return "Relay accepted; waiting for destination outcome"; + case "spawned": + return "Destination process spawned; readiness not yet confirmed"; + case "listening": + return "Destination harness listening; workload readiness not yet confirmed"; + case "ready": + return "Destination reported ready"; + case "rejected": + return "Destination rejected Start. Check its agent setup, compatibility, or an existing run, then refresh and retry."; + default: + return "Outcome unknown; retrying the same operation, not launching a replacement"; + } +} diff --git a/desktop/src/features/hosts/transport.test.mjs b/desktop/src/features/hosts/transport.test.mjs new file mode 100644 index 00000000000..f06a1af4ac0 --- /dev/null +++ b/desktop/src/features/hosts/transport.test.mjs @@ -0,0 +1,362 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ReadOnlyRelayClient } from "../../shared/api/readOnlyRelayClient.ts"; +import { createHostPublicationJournal } from "./pendingPublication.ts"; +import { reconcileHost } from "./registration.ts"; +import { fixture } from "./hostTestFixtures.mjs"; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); +function transport(t, { history, publish, connect } = {}) { + const previousWindow = globalThis.window; + const sent = []; + const closed = []; + const timers = new Map(); + let timerId = 0; + let channel; + const emit = (...frames) => + channel.onmessage( + frames.map((frame) => ({ type: "Text", data: JSON.stringify(frame) })), + ); + globalThis.window = { + setTimeout: (callback) => { + timers.set(++timerId, callback); + return timerId; + }, + clearTimeout: (id) => timers.delete(id), + __TAURI_INTERNALS__: { + transformCallback: () => 1, + invoke: async (command, args) => { + if (command === "plugin:websocket|connect") { + channel = args.onMessage; + if (connect) return connect(); + setImmediate(() => emit(["AUTH", "challenge"])); + return 7; + } + if (command === "create_auth_event") + return JSON.stringify({ id: "auth" }); + if (command === "plugin:websocket|disconnect") { + closed.push(args.id); + return; + } + if (command === "plugin:websocket|send") { + const frame = JSON.parse(args.message.data); + sent.push(frame); + if (frame[0] === "AUTH") emit(["OK", "auth", true, ""]); + if (frame[0] === "REQ") await history?.(frame, emit); + if (frame[0] === "EVENT") { + if (publish) await publish(frame, emit); + else emit(["OK", frame[1].id, true, ""]); + } + return; + } + throw new Error(`Unexpected IPC command: ${command}`); + }, + }, + }; + const client = new ReadOnlyRelayClient("wss://fixture.invalid"); + t.after(() => { + client.disconnect(); + globalThis.window = previousWindow; + }); + return { + client, + sent, + closed, + timers, + emit, + native: (frame) => channel.onmessage(frame), + }; +} +const run = (f, client, journal = f.journal) => + reconcileHost({ + owner: "a".repeat(64), + relay: client, + bridge: f.bridge, + journal, + active: () => true, + now: () => 100, + }); + +for (const stage of ["registration", "report"]) { + for (const mode of [ + "closed", + "closed-then-eose", + "partial-closed-then-eose", + "timeout", + "close", + "error", + "send-failed", + ]) { + test(`${stage} history ${mode} blocks all publication through the actual client`, async (t) => { + const f = fixture(); + const registration = await f.bridge.registration(); + const report = await f.bridge.report(registration); + let reached = false; + const wire = transport(t, { + history: ([, id, filter], emit) => { + if (filter["#l"][0] !== stage) { + emit(["EVENT", id, registration], ["EOSE", id]); + return; + } + reached = true; + if (mode.startsWith("partial")) + emit([ + "EVENT", + id, + stage === "registration" ? registration : report, + ]); + if (mode.includes("closed")) + emit(["CLOSED", id, "error: host history unavailable"]); + if (mode.endsWith("eose")) emit(["EOSE", id]); + if (mode === "close" || mode === "error") + wire.native({ type: mode === "close" ? "Close" : "Error" }); + if (mode === "send-failed") throw new Error("send failed"); + }, + }); + const rejected = assert.rejects( + run(f, wire.client), + /closed|disconnected|Timed out|send failed/, + ); + while (!reached) await flush(); + if (mode === "timeout") + for (const callback of [...wire.timers.values()]) callback(); + await rejected; + assert.equal(wire.sent.filter((frame) => frame[0] === "EVENT").length, 0); + }); + } +} + +test("EOSE with complete empty history permits publication only after accepted ACK", async (t) => { + const f = fixture(); + let acknowledge; + const wire = transport(t, { + history: ([, id], emit) => emit(["EOSE", id]), + publish: ([, event], emit) => { + acknowledge = () => emit(["OK", event.id, true, ""]); + }, + }); + let completed = false; + const result = run(f, wire.client).then((value) => { + completed = true; + return value; + }); + while (!acknowledge) await flush(); + assert.equal(completed, false); + assert.equal(wire.sent.filter((frame) => frame[0] === "EVENT").length, 1); + acknowledge(); + await flush(); + assert.equal(completed, false); + assert.equal(wire.sent.filter((frame) => frame[0] === "EVENT").length, 2); + acknowledge(); + assert.equal((await result).rows.length, 1); +}); + +test("negative registration ACK blocks report construction and success", async (t) => { + const f = fixture(); + f.bridge.report = async () => + assert.fail("report before accepted registration"); + const wire = transport(t, { + history: ([, id], emit) => emit(["EOSE", id]), + publish: ([, event], emit) => emit(["OK", event.id, false, "rejected"]), + }); + await assert.rejects(run(f, wire.client), /rejected/); + assert.equal(wire.sent.filter((frame) => frame[0] === "EVENT").length, 1); +}); + +test("disconnect during delayed native connect closes the orphan and cannot resurrect a publisher", async (t) => { + let resolve; + const wire = transport(t, { + connect: () => + new Promise((r) => { + resolve = r; + }), + }); + const pending = wire.client.fetchEvents({ kinds: [50000], limit: 1000 }); + const rejected = assert.rejects(pending, /cancelled/); + wire.client.disconnect(); + resolve(99); + await rejected; + assert.deepEqual(wire.closed, [99]); + assert.equal(wire.sent.length, 0); + assert.equal(wire.timers.size, 0); +}); + +test("concurrent reads cannot bypass pending authentication", async (t) => { + let resolve; + const wire = transport(t, { + connect: () => + new Promise((r) => { + resolve = r; + }), + }); + const first = wire.client.fetchEvents({ kinds: [50000], limit: 1000 }); + const rejectedFirst = assert.rejects(first, /disconnected/); + resolve(99); + await flush(); + const second = wire.client.fetchEvents({ kinds: [50000], limit: 1000 }); + const rejectedSecond = assert.rejects(second, /disconnected/); + await flush(); + assert.equal(wire.sent.length, 0); + wire.client.disconnect(); + await Promise.all([rejectedFirst, rejectedSecond]); +}); + +test("AUTH delivered before native connect resolves is not lost", async (t) => { + let resolve; + const wire = transport(t, { + connect: () => + new Promise((r) => { + resolve = r; + }), + history: ([, id], emit) => emit(["EOSE", id]), + }); + const pending = wire.client.fetchEvents({ kinds: [50000], limit: 1000 }); + wire.emit(["AUTH", "early challenge"]); + resolve(99); + assert.deepEqual(await pending, []); + assert.equal(wire.sent[0][0], "AUTH"); +}); + +for (const stage of ["registration", "report"]) { + test(`a duplicate ${stage} history through the actual client blocks construction`, async (t) => { + const f = fixture(); + const registration = await f.bridge.registration(); + const report = await f.bridge.report(registration); + f.bridge.registration = f.bridge.report = async () => + assert.fail("construction after non-advancing history"); + const wire = transport(t, { + history: ([, id, filter], emit) => { + assert.equal(filter.limit, 1000); + const event = + filter["#l"][0] === "registration" ? registration : report; + const count = filter["#l"][0] === stage ? 1000 : 1; + for (let i = 0; i < count; i++) emit(["EVENT", id, event]); + emit(["EOSE", id]); + }, + }); + await assert.rejects(run(f, wire.client), /cursor did not advance/); + assert.equal(wire.sent.filter((frame) => frame[0] === "EVENT").length, 0); + }); + + test(`accepted ${stage} with lost transport ACK is not duplicated on reconnect`, async (t) => { + const f = fixture(); + let lost = false; + let loseAck = true; + const wire = transport(t, { + history: async ([, id, filter], emit) => { + for (const event of await f.relay.fetchEvents(filter)) + emit(["EVENT", id, event]); + emit(["EOSE", id]); + }, + publish: async ([, event], emit) => { + await f.relay.publishEvent(event); + if ( + loseAck && + event.tags.some( + (tag) => + tag[0] === "l" && + tag[1] === (stage === "report" ? "profile" : stage), + ) + ) { + lost = true; + return; + } + emit(["OK", event.id, true, ""]); + }, + }); + const rejected = assert.rejects( + run(f, wire.client), + /Timed out publishing/, + ); + while (!lost) await flush(); + for (const callback of [...wire.timers.values()]) callback(); + await rejected; + wire.client.disconnect(); + loseAck = false; + const snapshot = await run(f, wire.client); + assert.equal(snapshot.rows.length, 1); + assert.equal(f.writes.length, 2); + assert.equal(wire.sent.filter((frame) => frame[0] === "EVENT").length, 2); + }); +} + +for (const stage of ["registration", "report"]) { + for (const reload of [false, true]) { + test(`late ${stage} commit AFTER retry history reuses exact signed event${reload ? " after journal reload" : ""}`, async (t) => { + const f = fixture(); + let held; + let retryRead = false; + let retrying = false; + const wire = transport(t, { + history: async ([, id, filter], emit) => { + const events = await f.relay.fetchEvents(filter); + if (retrying && filter["#l"][0] === stage) { + assert.ok(!events.some((e) => e.id === held.id)); + retryRead = true; + } + for (const event of events) emit(["EVENT", id, event]); + emit(["EOSE", id]); + }, + publish: async ([, event], emit) => { + if ( + event.tags.some( + (tag) => + tag[0] === "l" && + tag[1] === (stage === "report" ? "profile" : stage), + ) + ) { + if (!held) { + held = event; + assert.deepEqual( + f.journal.load()[ + stage === "report" ? "report" : "registration" + ], + event, + ); + return; // Detached old ingest remains uncommitted past timeout. + } + assert.equal(retryRead, true); + assert.deepEqual(event, held); // All seven signed fields, not just ID. + await f.relay.publishEvent(event); + await f.relay.publishEvent(held); // Old EVENT commits after retry read/send. + } else { + await f.relay.publishEvent(event); + } + emit(["OK", event.id, true, ""]); + }, + }); + const rejected = assert.rejects( + run(f, wire.client), + /Timed out publishing/, + ); + while (!held) await flush(); + for (const callback of [...wire.timers.values()]) callback(); + await rejected; + wire.client.disconnect(); + retrying = true; + f.setNow(130); + // A fresh journal object reads only serialized storage; no uncertain-event + // object or decrypted report is retained by the restarted controller. + const journal = reload + ? createHostPublicationJournal( + "wss://fixture.invalid", + "a".repeat(64), + f.storage, + ) + : f.journal; + const snapshot = await run(f, wire.client, journal); + assert.equal(snapshot.rows.length, 1); + assert.equal(f.events.length, 2); + assert.equal( + f.events.filter((e) => + e.tags.some( + (tag) => tag[1] === (stage === "report" ? "profile" : stage), + ), + ).length, + 1, + ); + assert.equal(wire.sent.filter((frame) => frame[0] === "EVENT").length, 3); + assert.equal(journal.load(), undefined); + }); + } +} diff --git a/desktop/src/features/hosts/useHostPresence.ts b/desktop/src/features/hosts/useHostPresence.ts new file mode 100644 index 00000000000..af1bfd55940 --- /dev/null +++ b/desktop/src/features/hosts/useHostPresence.ts @@ -0,0 +1,66 @@ +import { useEffect } from "react"; +import { ReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; +import { invokeTauri } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; + +/** Launcher reachability is independent of human idle/away and profile refreshes. */ +export function useHostPresence( + owner: string | undefined, + relayUrl: string | undefined, + registration: RelayEvent | undefined, +) { + const binding = registration ? JSON.stringify(registration) : undefined; + useEffect(() => { + if (!owner || !relayUrl || !binding) return; + const registration = JSON.parse(binding) as RelayEvent; + let active = true; + let seq = 0; + let pending = false; + const run = crypto.randomUUID().replaceAll("-", ""); + const client = new ReadOnlyRelayClient(relayUrl); + const pulse = async () => { + if (!active || pending) return; + pending = true; + try { + const event = await invokeTauri("create_host_presence", { + expectedOwner: owner, + registration, + run, + seq: seq++, + status: "online", + }); + if (active) await client.publishEvent(event); + } catch { + // No optimistic liveness. Retry next heartbeat; prior leases expire. + client.disconnect(); + } finally { + pending = false; + } + }; + void pulse(); + const timer = window.setInterval(() => void pulse(), 60_000); + const wake = () => void pulse(); + window.addEventListener("online", wake); + window.addEventListener("focus", wake); + return () => { + active = false; + window.clearInterval(timer); + window.removeEventListener("online", wake); + window.removeEventListener("focus", wake); + client.disconnect(); + // Best effort on unmount; identity switches are rejected natively, then TTL wins. + const shutdown = new ReadOnlyRelayClient(relayUrl); + void invokeTauri("create_host_presence", { + expectedOwner: owner, + registration, + run, + seq: seq++, + status: "offline", + }) + .then((event) => shutdown.publishEvent(event)) + .catch(() => {}) + .finally(() => shutdown.disconnect()); + }; + // The signed binding is immutable; refreshed snapshot objects must not restart a run. + }, [owner, relayUrl, binding]); +} diff --git a/desktop/src/features/hosts/useHostRegistration.ts b/desktop/src/features/hosts/useHostRegistration.ts new file mode 100644 index 00000000000..7bd0eb5245d --- /dev/null +++ b/desktop/src/features/hosts/useHostRegistration.ts @@ -0,0 +1,137 @@ +import { useHostStartReceiver } from "./useHostStart"; +import { useHostPresence } from "./useHostPresence"; +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { invokeTauri } from "@/shared/api/tauri"; +import { ReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { + hostQueryKey, + type HostBridge, + type HostSnapshot, + type HostReport, + type LocalHost, +} from "./registration"; + +import { createHostRegistrationLifecycle } from "./registrationLifecycle"; +import { createHostPublicationJournal } from "./pendingPublication"; +import { hostNativeDrain } from "./hostNativeDrain"; + +export const HOST_REFRESH = "buzz:refresh-hosts"; +const EMPTY: HostSnapshot = { rows: [], checking: true }; + +export function useHostSnapshot() { + const { activeCommunity } = useCommunities(); + const { data: identity } = useIdentityQuery(); + return ( + useQuery({ + queryKey: hostQueryKey(activeCommunity?.relayUrl, identity?.pubkey), + queryFn: async () => EMPTY, + enabled: false, + }).data ?? EMPTY + ); +} + +/** App lifecycle, not Agents-page lifecycle. No cross-community singleton cache. */ +export function useHostRegistration( + owner: string | undefined, + relayUrl: string | undefined, +) { + const queryClient = useQueryClient(); + const snapshot = + useQuery({ + queryKey: hostQueryKey(relayUrl, owner), + queryFn: async () => EMPTY, + enabled: false, + }).data ?? EMPTY; + const localRegistration = snapshot.rows.find( + (row) => row.host === snapshot.local?.host, + )?.registration; + useHostPresence(owner, relayUrl, localRegistration); + useHostStartReceiver(owner, relayUrl, !!localRegistration); + useEffect(() => { + if (!owner || !relayUrl) return; + const key = hostQueryKey(relayUrl, owner); + const bridge: HostBridge = { + local: () => + invokeTauri("get_local_host", { expectedOwner: owner }), + registration: () => + invokeTauri("create_host_registration", { + expectedOwner: owner, + }), + report: (registration) => + invokeTauri("create_host_report", { + expectedOwner: owner, + registration, + }), + inspect: (registration) => + invokeTauri("inspect_host_registration", { + expectedOwner: owner, + registration, + }), + decode: (registration, report) => + invokeTauri("decode_host_report", { + expectedOwner: owner, + registration, + report, + }), + }; + const lifecycle = createHostRegistrationLifecycle({ + owner, + bridge, + journal: createHostPublicationJournal(relayUrl, owner), + connect: () => { + const client = new ReadOnlyRelayClient(relayUrl); + return { + // Only HTTP preserves the before_id keyset extension. Owner signing + // stays native; the socket is used solely for acknowledged writes. + fetchEvents: (filter) => + invokeTauri("get_host_history_page", { + expectedOwner: owner, + relayUrl, + filter, + }), + publishEvent: (event) => client.publishEvent(event), + disconnect: () => client.disconnect(), + }; + }, + now: () => Math.floor(Date.now() / 1000), + after: hostNativeDrain.wait(), + checking: () => + queryClient.setQueryData(key, (previous) => ({ + ...(previous ?? EMPTY), + checking: true, + })), + success: (result) => { + queryClient.setQueryData(key, result); + }, + failure: () => + queryClient.setQueryData(key, (previous) => ({ + ...(previous ?? EMPTY), + checking: false, + // Native/transport diagnostics may contain private paths or payloads. + error: + "Could not verify host history or relay acceptance; retry shortly", + })), + }); + const refresh = () => { + void lifecycle.refresh(); + }; + refresh(); + const timer = window.setInterval(refresh, 30_000); + window.addEventListener(HOST_REFRESH, refresh); + window.addEventListener("online", refresh); + window.addEventListener("focus", refresh); + return () => { + hostNativeDrain.hold(lifecycle.stop()); + window.clearInterval(timer); + window.removeEventListener(HOST_REFRESH, refresh); + window.removeEventListener("online", refresh); + window.removeEventListener("focus", refresh); + // Decrypted private metadata must not survive an identity/community switch. + queryClient.removeQueries({ queryKey: key, exact: true }); + }; + }, [owner, relayUrl, queryClient]); +} diff --git a/desktop/src/features/hosts/useHostStart.ts b/desktop/src/features/hosts/useHostStart.ts new file mode 100644 index 00000000000..cb5f8d33a21 --- /dev/null +++ b/desktop/src/features/hosts/useHostStart.ts @@ -0,0 +1,101 @@ +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { invokeTauri } from "@/shared/api/tauri"; +import type { MoveProgress } from "./moveSelection"; +import { hostNativeDrain } from "./hostNativeDrain"; + +export type StartProgress = { + operation: string; + action?: "start" | "stop"; + created_at: number; + current: boolean; + agent: string; + host: string; + run: string; + status: string; + error?: string; +}; +type Snapshot = { + operations: StartProgress[]; + moves: MoveProgress[]; + error?: string; +}; +const empty: Snapshot = { operations: [], moves: [] }; +const key = (owner?: string, relay?: string) => ["host-start", relay, owner]; +export const START_REFRESH = "buzz:refresh-host-start"; + +/** Receiver is scoped to the app, not the Hosts page. Native owns fsynced + * encrypted outbox and execution ledger; no browser cache is launch authority. */ +export function useHostStartReceiver( + owner?: string, + relay?: string, + enabled = false, +) { + const client = useQueryClient(); + useEffect(() => { + if (!owner || !relay || !enabled) return; + let active = true; + let pending: Promise | undefined; + const refresh = () => { + if (!active || pending) return; + pending = (async () => { + try { + const snapshot = await invokeTauri<{ + operations: StartProgress[]; + moves: MoveProgress[]; + errors: string[]; + }>("pump_host_start", { + expectedOwner: owner, + expectedRelay: relay, + }); + if (active) + client.setQueryData(key(owner, relay), { + operations: snapshot.operations, + moves: snapshot.moves ?? [], + error: snapshot.errors.length + ? "Destination transport has unconfirmed operations; retries continue independently." + : undefined, + }); + } catch { + if (active) + client.setQueryData(key(owner, relay), (old) => ({ + operations: old?.operations ?? [], + moves: old?.moves ?? [], + error: + "Start transport unconfirmed. Retrying the same saved operation; no replacement will be launched.", + })); + } + })().finally(() => { + pending = undefined; + }); + }; + void hostNativeDrain.wait().then(refresh); + const timer = window.setInterval(refresh, 5_000); + window.addEventListener(START_REFRESH, refresh); + window.addEventListener("online", refresh); + window.addEventListener("focus", refresh); + return () => { + active = false; + if (pending) hostNativeDrain.hold(pending); + window.clearInterval(timer); + window.removeEventListener(START_REFRESH, refresh); + window.removeEventListener("online", refresh); + window.removeEventListener("focus", refresh); + client.removeQueries({ queryKey: key(owner, relay), exact: true }); + }; + }, [owner, relay, enabled, client]); +} + +export function useHostStartProgress() { + const { activeCommunity } = useCommunities(); + const { data: identity } = useIdentityQuery(); + return ( + useQuery({ + queryKey: key(identity?.pubkey, activeCommunity?.relayUrl), + queryFn: async () => empty, + enabled: false, + }).data ?? empty + ); +} diff --git a/desktop/src/features/messages/ui/LiveMentionAutocomplete.tsx b/desktop/src/features/messages/ui/LiveMentionAutocomplete.tsx new file mode 100644 index 00000000000..686010d19a3 --- /dev/null +++ b/desktop/src/features/messages/ui/LiveMentionAutocomplete.tsx @@ -0,0 +1,21 @@ +import type { ComponentProps } from "react"; +import { usePresenceRuns } from "@/features/presence/usePresenceRuns"; +import { MentionAutocomplete as MentionAutocompleteView } from "./MentionAutocomplete"; + +/** Fetch only visible agent placements, once per picker rather than per row. */ +export function MentionAutocomplete( + props: ComponentProps, +) { + const presence = usePresenceRuns( + props.suggestions.flatMap((suggestion) => + suggestion.isAgent && suggestion.pubkey ? [suggestion.pubkey] : [], + ), + ); + return ( + + ); +} diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index be3ec6938ef..8ebc1dedff0 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -407,3 +407,63 @@ test("agents without trustworthy provenance omit management provenance", () => { false, ); }); + +test("live host names use the cloud marker even without a name collision", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const pubkey = "b".repeat(64); + const props = { + suggestions: [{ pubkey, displayName: "Agent Ada", isAgent: true }], + selectedIndex: 0, + onSelect: () => {}, + presenceNow: 100, + presenceRuns: { + [pubkey]: [ + { + run: "c".repeat(32), + seq: 0, + status: "online", + expires_at: 101, + location: { host: "a".repeat(64), label: "Workshop" }, + registration: null, + }, + ], + }, + }; + const view = render(React.createElement(MentionAutocomplete, props)); + const marker = view.getByRole("img", { name: "Running on Workshop" }); + assert.match( + marker.querySelector("svg").getAttribute("class"), + /lucide-cloud/, + ); + assert.equal(marker.textContent, "Workshop"); + view.rerender( + React.createElement(MentionAutocomplete, { ...props, presenceNow: 101 }), + ); + assert.equal(view.queryByRole("img", { name: "Running on Workshop" }), null); +}); + +test("duplicate agent without a live host reports management, not location", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const view = render( + React.createElement(MentionAutocomplete, { + suggestions: [ + { ...suggestion("managed-here"), pubkey: "a".repeat(64) }, + { ...suggestion("managed-elsewhere"), pubkey: "b".repeat(64) }, + ], + selectedIndex: 0, + onSelect: () => {}, + }), + ); + const marker = view.getByTestId("mention-agent-provenance"); + assert.equal(marker.getAttribute("aria-label"), "Not managed on this device"); + assert.equal(marker.getAttribute("title"), "Not managed on this device"); + assert.equal(marker.textContent, ""); + assert.match( + marker.querySelector("svg").getAttribute("class"), + /lucide-cloud/, + ); +}); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 08b7dfb37ce..910d730666f 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,6 +1,7 @@ import * as React from "react"; +import type { PresenceRuns } from "@/features/presence/runPresence"; import { Bot, ChevronRight, Pin, Users } from "lucide-react"; -import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; +import { AgentHostMarker } from "@/features/agents/ui/AgentHostMarker"; import { motion } from "motion/react"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; @@ -36,6 +37,8 @@ export type MentionSuggestion = { type MentionAutocompleteProps = { suggestions: MentionSuggestion[]; + presenceRuns?: PresenceRuns; + presenceNow?: number; selectedIndex: number; onFetchMore?: () => void; onSelect: (suggestion: MentionSuggestion) => void; @@ -58,6 +61,8 @@ export function showMentionAgentProvenanceMarker( export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestions, + presenceRuns, + presenceNow = Date.now() / 1000, selectedIndex, onFetchMore, onSelect, @@ -363,9 +368,18 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ data-testid="mention-agent-icon" /> agent - {showAgentProvenanceMarker ? ( - - ) : null} + ) : suggestion.role ? ( {label} + {" "} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0cc8650795e..d3448a277e1 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -50,7 +50,7 @@ import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner"; import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; import { EmojiAutocomplete } from "./EmojiAutocomplete"; -import { MentionAutocomplete } from "./MentionAutocomplete"; +import { MentionAutocomplete } from "./LiveMentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; diff --git a/desktop/src/features/messages/ui/MessageHeader.tsx b/desktop/src/features/messages/ui/MessageHeader.tsx index 24bef0f542b..c74bb6706a5 100644 --- a/desktop/src/features/messages/ui/MessageHeader.tsx +++ b/desktop/src/features/messages/ui/MessageHeader.tsx @@ -1,5 +1,8 @@ import * as React from "react"; +import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; + import { cn } from "@/shared/lib/cn"; type MessageHeaderRowProps = { @@ -104,3 +107,40 @@ export function MessageAuthorText({ ); } + +/** Author navigation and provenance always refer to the same exact identity. */ +export function MessageAuthorIdentity({ + pubkey, + ownerPubkey, + role, + displayName, + children, +}: { + pubkey?: string | null; + ownerPubkey?: string | null; + role?: string; + displayName: string; + children: React.ReactNode; +}) { + return ( + <> + {pubkey ? ( + + + + ) : ( + children + )} + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 02858dac6a0..33fc26d13fe 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -50,6 +50,7 @@ import { toast } from "sonner"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, + MessageAuthorIdentity, MessageHeaderRow, MessageMetaSegments, } from "./MessageHeader"; @@ -646,22 +647,14 @@ export const MessageRow = React.memo( const headerNode = isDisplayedAsContinuation ? null : ( - {message.pubkey ? ( - - - - ) : ( - authorNode - )} + + {authorNode} + {/* Author is not a segment: "Alice 9:53 AM" needs no divider. */} | null = null; - function handlePresenceEvent(event: { pubkey: string; content: string }) { + function handlePresenceEvent(event: RelayEvent) { if (isCancelled) return; const parsed = parseLivePresenceEvent(event); if (!parsed) return; const { pubkey, status } = parsed; + void queryClient.invalidateQueries({ + queryKey: ["presence-runs"], + predicate: (query) => query.queryKey.slice(3).includes(pubkey), + }); + if (event.tags.some((tag) => tag[0] === "run")) { + // Offline ends one run, not the identity. Ask the relay for the aggregate. + void queryClient.invalidateQueries({ + queryKey: ["presence"], + predicate: (query) => + presenceQueryWantsPubkey(query.queryKey, pubkey), + }); + return; + } queryClient.setQueriesData( { queryKey: ["presence"], @@ -150,9 +167,23 @@ export function usePresenceSubscription() { }); function reconcileActiveQueries() { - reconciler.setAuthors( - activePresencePubkeys(queryClient.getQueryCache().getAll()), - ); + reconciler.setAuthors([ + ...new Set([ + ...activePresencePubkeys(queryClient.getQueryCache().getAll()), + ...queryClient + .getQueryCache() + .getAll() + .filter( + (query) => + query.isActive() && query.queryKey[0] === "presence-runs", + ) + .flatMap((query) => + query.queryKey + .slice(3) + .filter((key): key is string => typeof key === "string"), + ), + ]), + ]); } function scheduleReconcile() { @@ -175,8 +206,10 @@ export function usePresenceSubscription() { reconcileActiveQueries(); const unsubReconnect = relayClient.subscribeToReconnects(() => { - if (!isCancelled) + if (!isCancelled) { void queryClient.invalidateQueries({ queryKey: ["presence"] }); + void queryClient.invalidateQueries({ queryKey: ["presence-runs"] }); + } }); return () => { diff --git a/desktop/src/features/presence/runPresence.test.mjs b/desktop/src/features/presence/runPresence.test.mjs new file mode 100644 index 00000000000..0592582df0f --- /dev/null +++ b/desktop/src/features/presence/runPresence.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { activeRuns, locationLabels, nextRunExpiry } from "./runPresence.ts"; +const run = (id, label, expires_at = 280) => ({ + run: id, + seq: 1, + status: "online", + expires_at, + location: label ? { host: id, label } : null, + registration: null, +}); +test("location requires its own unexpired run, never aggregate presence", () => { + const runs = [run("a", "Workshop"), run("b", "Office", 300), run("c", null)]; + assert.deepEqual(locationLabels(runs, 100), ["Office", "Workshop"]); + assert.deepEqual(locationLabels(runs, 280), ["Office"]); + assert.deepEqual(locationLabels(runs, 300), []); + assert.equal(nextRunExpiry({ agent: runs }, 280), 300); + assert.equal(nextRunExpiry({ agent: runs }, 300), undefined); +}); +test("stopping one placement leaves the other; repeated labels coalesce", () => { + const first = { ...run("a", "Workshop"), status: "offline" }; + const runs = [first, run("b", "Office"), run("c", "Office")]; + assert.equal(activeRuns(runs, 100).length, 2); + assert.deepEqual(locationLabels(runs, 100), ["Office"]); + assert.deepEqual(locationLabels(undefined, 100), []); +}); diff --git a/desktop/src/features/presence/runPresence.ts b/desktop/src/features/presence/runPresence.ts new file mode 100644 index 00000000000..a412d67c065 --- /dev/null +++ b/desktop/src/features/presence/runPresence.ts @@ -0,0 +1,34 @@ +/** Relay-bounded leases; snapshot reads never renew these deadlines. */ +export type PresenceRun = { + run: string; + seq: number; + status: "online" | "away" | "offline"; + expires_at: number; + location: { host: string; label: string } | null; + registration: string | null; +}; +export type PresenceRuns = Record; + +export function activeRuns(runs: PresenceRun[] | undefined, now: number) { + return (runs ?? []).filter( + (run) => run.status !== "offline" && run.expires_at > now, + ); +} + +export function locationLabels(runs: PresenceRun[] | undefined, now: number) { + return [ + ...new Set( + activeRuns(runs, now).flatMap((run) => + run.location ? [run.location.label] : [], + ), + ), + ].sort(); +} + +export function nextRunExpiry(data: PresenceRuns | undefined, now: number) { + const deadlines = Object.values(data ?? {}) + .flat() + .map((run) => run.expires_at) + .filter((deadline) => deadline > now); + return deadlines.length ? Math.min(...deadlines) : undefined; +} diff --git a/desktop/src/features/presence/usePresenceRuns.test.mjs b/desktop/src/features/presence/usePresenceRuns.test.mjs new file mode 100644 index 00000000000..33a08c8fbc1 --- /dev/null +++ b/desktop/src/features/presence/usePresenceRuns.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + CommunitiesProvider, + useCommunities, +} from "../communities/useCommunities.tsx"; +import { usePresenceRuns } from "./usePresenceRuns.ts"; +import { AgentHostMarker } from "../agents/ui/AgentHostMarker.tsx"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 15)); +test("live markers expire without a network callback and reject late previous-scope data", async (t) => { + const dom = new JSDOM("
", { + url: "https://fixture.invalid", + }); + const globals = [ + "window", + "document", + "localStorage", + "IS_REACT_ACT_ENVIRONMENT", + ]; + const saved = Object.fromEntries( + globals.map((key) => [key, globalThis[key]]), + ); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { id: "a", name: "A", relayUrl: "wss://a.invalid" }, + { id: "b", name: "B", relayUrl: "wss://b.invalid" }, + ]), + ); + localStorage.setItem("buzz-active-community-id", "a"); + const client = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const owner = "a".repeat(64), + agent = "b".repeat(64); + client.setQueryData(["identity"], { pubkey: owner }); + const calls = [], + replies = []; + dom.window.__TAURI_INTERNALS__ = { + invoke: (command, args) => { + assert.equal(command, "get_presence_runs"); + calls.push(args); + return new Promise((resolve, reject) => + replies.push({ resolve, reject }), + ); + }, + }; + const root = createRoot(document.getElementById("root")); + let communities, latest; + function View() { + communities = useCommunities(); + latest = usePresenceRuns([agent]); + return React.createElement(AgentHostMarker, { + runs: latest.data?.[agent], + now: latest.now, + }); + } + t.after(async () => { + await act(async () => root.unmount()); + client.clear(); + await flush(); + dom.window.close(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + }); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(View), + ), + ), + ); + await flush(); + }); + assert.deepEqual(calls[0], { + expectedOwner: owner, + relayUrl: "wss://a.invalid", + pubkeys: [agent], + }); + const run = { + run: "c".repeat(32), + seq: 0, + status: "online", + expires_at: Date.now() / 1000 + 0.15, + location: { host: owner, label: "Workshop" }, + registration: null, + }; + await act(async () => { + replies[0].resolve({ [agent]: [run] }); + await flush(); + }); + assert.equal( + document.querySelector('[title="Running on Workshop"]')?.textContent, + "Workshop", + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 180)); + }); + assert.equal(document.querySelector('[title="Running on Workshop"]'), null); + assert.equal(calls.length, 1, "expiry must not need a poll"); + void latest.refetch(); + await act(async () => { + communities.switchCommunity("b"); + await flush(); + }); + assert.equal(calls[2].relayUrl, "wss://b.invalid"); + await act(async () => { + replies[1].resolve({ + [agent]: [{ ...run, expires_at: Date.now() / 1000 + 100 }], + }); + await flush(); + }); + assert.equal( + document.querySelector('[title="Running on Workshop"]'), + null, + "old community response must not render in B", + ); + await act(async () => { + replies[2].reject(new Error("unavailable")); + await flush(); + }); + assert.equal(latest.isError, true); + assert.equal( + latest.data, + undefined, + "failed snapshot is unknown, not an empty offline snapshot", + ); + await act(async () => { + client.setQueryData(["identity"], { pubkey: "d".repeat(64) }); + await flush(); + }); + assert.equal(calls[3].expectedOwner, "d".repeat(64)); + await act(async () => { + replies[3].resolve({ [agent]: [] }); + await flush(); + }); + assert.deepEqual(latest.data, { [agent]: [] }); +}); diff --git a/desktop/src/features/presence/usePresenceRuns.ts b/desktop/src/features/presence/usePresenceRuns.ts new file mode 100644 index 00000000000..29f8027d719 --- /dev/null +++ b/desktop/src/features/presence/usePresenceRuns.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { invokeTauri } from "@/shared/api/tauri"; +import { useRelayConnection } from "@/shared/api/useRelayConnection"; +import { nextRunExpiry, type PresenceRuns } from "./runPresence"; + +/** Scope snapshots to the captured reader and community; errors are not offline. */ +export function usePresenceRuns(pubkeys: string[]) { + const { activeCommunity } = useCommunities(); + const { data: identity } = useIdentityQuery(); + const connected = useRelayConnection() === "connected"; + const owner = identity?.pubkey; + const relay = activeCommunity?.relayUrl; + const authors = [...new Set(pubkeys.map((key) => key.toLowerCase()))].sort(); + const query = useQuery({ + queryKey: ["presence-runs", relay, owner, ...authors], + enabled: !!owner && !!relay && authors.length > 0, + queryFn: () => + invokeTauri("get_presence_runs", { + expectedOwner: owner, + relayUrl: relay, + pubkeys: authors, + }), + refetchInterval: connected ? 60_000 : false, + staleTime: 30_000, + retry: false, + }); + const [now, setNow] = useState(() => Date.now() / 1000); + const expiry = nextRunExpiry(query.data, now); + useEffect(() => { + // Also reevaluate after a background/sleep interval; do not wait for a poll. + const update = () => setNow(Date.now() / 1000); + update(); + const timer = + expiry === undefined + ? undefined + : window.setTimeout( + update, + Math.max(0, expiry * 1000 - Date.now()) + 1, + ); + window.addEventListener("focus", update); + document.addEventListener("visibilitychange", update); + return () => { + window.clearTimeout(timer); + window.removeEventListener("focus", update); + document.removeEventListener("visibilitychange", update); + }; + }, [expiry]); + return { ...query, now: Math.max(now, Date.now() / 1000) }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx b/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx index 531222f0ba2..148d57aa8ea 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; +import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { CopyButton } from "@/features/agents/ui/CopyButton"; import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection"; import { @@ -16,6 +17,7 @@ import { Button } from "@/shared/ui/button"; export function getUserProfilePanelHeaderContent({ agentSettingsMenu, effectivePubkey, + ownerPubkey, logCopyValue, logSubtitle, onBack, @@ -25,6 +27,7 @@ export function getUserProfilePanelHeaderContent({ }: { agentSettingsMenu: ReactNode; effectivePubkey: string | null; + ownerPubkey?: string | null; logCopyValue?: string | null; logSubtitle?: string | null; onBack: () => void; @@ -47,6 +50,13 @@ export function getUserProfilePanelHeaderContent({ subtitleTitle={logSubtitle ?? undefined} title={title} /> + {view !== "summary" ? ( + + ) : null} ); const headerActions = ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 30da0f22417..1244e324a25 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -1,3 +1,4 @@ +import { LiveAgentHostMarker } from "@/features/agents/ui/LiveAgentHostMarker"; import * as React from "react"; import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; @@ -410,6 +411,11 @@ export function ProfileSummaryView({ profile={profile} userStatus={userStatus} /> + {isBot && pubkey ? ( +
+ +
+ ) : null}
{canInstantiateAgent ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index d19a5fa19d4..af4431e76bb 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -1,6 +1,8 @@ import * as React from "react"; import { Activity, Headphones, MessageSquare } from "lucide-react"; +import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; + import { useChannelsQuery } from "@/features/channels/hooks"; import { useUserProfileQuery, @@ -391,6 +393,11 @@ function UserProfilePopoverBody({
+ {isBotProfile && botIdenticonValue ? ( Promise; - stopManagedAgent: (pubkey: string) => Promise; + startManagedAgent: StartManagedAgent; + stopManagedAgent: (input: { + pubkey: string; + selectedRunId?: string | null; + expectedRelayUrl?: string | null; + }) => Promise; }) { const handleAgentPrimaryAction = React.useCallback(async () => { if (!managedAgent) return; diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 6fca32350aa..bc7884c4dd9 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -7,6 +7,7 @@ import { ContextMenuTrigger, } from "@/shared/ui/context-menu"; +import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu"; import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; @@ -336,6 +337,12 @@ export function ChannelMenuButton({ variant="sidebar" /> ) : null} + {channel.channelType === "dm" && dmParticipants?.length === 1 ? ( + + ) : null} {activeWorking ? ( shared summary/profile actions -> real IPC wrapper, +// not an injected Start callback that could accidentally discard the scope. +const raw = (overrides = {}) => ({ + pubkey: "ab".repeat(32), + selected_relay_url: "wss://clicked.example", + selected_run_id: null, + relay_url: "wss://legacy-pin.example", + backend: { type: "local" }, + status: "stopped", + ...overrides, +}); + +async function withIpc(handler, run) { + const previous = globalThis.window; + globalThis.window = { __TAURI_INTERNALS__: { invoke: handler } }; + try { + await run(); + } finally { + globalThis.window = previous; + } +} + +const start = ({ pubkey, expectedRelayUrl }) => + startManagedAgent(pubkey, { expectedRelayUrl }); + +for (const backend of [ + { type: "local" }, + { type: "provider", id: "test", config: {} }, +]) { + test(`unstarted ${backend.type} summary carries selected workspace through IPC without a run`, async () => { + const summary = raw({ backend }); + const calls = []; + await withIpc( + async (command, args) => { + calls.push({ command, args }); + return summary; + }, + async () => { + await startManagedAgentWithRules({ + agent: fromRawManagedAgent(summary), + startManagedAgent: start, + }); + }, + ); + assert.deepEqual(calls, [ + { + command: "start_managed_agent", + args: { + pubkey: summary.pubkey, + expectedRelayUrl: summary.selected_relay_url, + expectedSignerPubkey: null, + }, + }, + ]); + }); +} + +test("missing wire scope never falls back to the legacy pin or invokes Start", async () => { + const summary = raw(); + delete summary.selected_relay_url; + await withIpc( + async () => assert.fail("must not invoke"), + async () => { + await assert.rejects( + startManagedAgentWithRules({ + agent: fromRawManagedAgent(summary), + startManagedAgent: start, + }), + /without a selected community/, + ); + }, + ); +}); + +test("Restart keeps the clicked wire scope through Stop and real Start IPC", async () => { + const agent = fromRawManagedAgent( + raw({ status: "running", selected_run_id: "clicked-run" }), + ); + await withIpc( + async (command, args) => { + assert.equal(command, "start_managed_agent"); + assert.equal(args.expectedRelayUrl, "wss://clicked.example"); + return raw(); + }, + async () => { + await respawnManagedAgentWithRules({ + agent, + startManagedAgent: start, + stopManagedAgent: async (input) => { + assert.equal(input.selectedRunId, "clicked-run"); + assert.equal(input.expectedRelayUrl, "wss://clicked.example"); + await Promise.resolve(); + agent.selectedRelayUrl = "wss://other.example"; + }, + }); + }, + ); +}); diff --git a/desktop/src/shared/api/managedAgentWire.ts b/desktop/src/shared/api/managedAgentWire.ts new file mode 100644 index 00000000000..b43e3ae8e69 --- /dev/null +++ b/desktop/src/shared/api/managedAgentWire.ts @@ -0,0 +1,98 @@ +import type { ManagedAgent, ManagedAgentBackend } from "./types"; +import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; + +export type RawManagedAgent = { + selected_run_id?: string | null; + selected_relay_url?: string | null; + pubkey: string; + name: string; + persona_id: string | null; + // Optional: pre-feature fixtures may omit it. The record's harness/runtime id. + runtime?: string | null; + team_id?: string | null; + relay_url: string; + acp_command: string; + agent_command: string; + agent_command_override?: string | null; + agent_args: string[]; + mcp_command: string; + turn_timeout_seconds: number; + idle_timeout_seconds: number | null; + max_turn_duration_seconds: number | null; + parallelism: number; + system_prompt: string | null; + avatar_url?: string | null; + model: string | null; + model_source?: ManagedAgent["modelSource"]; + provider: string | null; + persona_out_of_date: boolean; + persona_orphaned: boolean; + needs_restart: boolean; + restart_diff?: RawRestartDiffEntry[]; + env_vars?: Record; + status: ManagedAgent["status"]; + pid: number | null; + created_at: string; + updated_at: string; + last_started_at: string | null; + last_stopped_at: string | null; + last_exit_code: number | null; + last_error: string | null; + last_error_code: number | null; + log_path: string; + start_on_app_launch: boolean; + auto_restart_on_config_change?: boolean; + backend: ManagedAgentBackend; + backend_agent_id: string | null; + // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. + respond_to?: ManagedAgent["respondTo"]; + respond_to_allowlist?: string[]; +}; + +export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { + return { + selectedRunId: agent.selected_run_id ?? null, + selectedRelayUrl: agent.selected_relay_url ?? null, + pubkey: agent.pubkey, + name: agent.name, + personaId: agent.persona_id, + runtime: agent.runtime ?? null, + teamId: agent.team_id ?? null, + relayUrl: agent.relay_url, + acpCommand: agent.acp_command, + agentCommand: agent.agent_command, + agentCommandOverride: agent.agent_command_override ?? null, + agentArgs: agent.agent_args, + mcpCommand: agent.mcp_command, + turnTimeoutSeconds: agent.turn_timeout_seconds, + idleTimeoutSeconds: agent.idle_timeout_seconds, + maxTurnDurationSeconds: agent.max_turn_duration_seconds, + parallelism: agent.parallelism, + systemPrompt: agent.system_prompt, + avatarUrl: agent.avatar_url ?? null, + model: agent.model, + modelSource: agent.model_source ?? null, + provider: agent.provider ?? null, + personaOutOfDate: agent.persona_out_of_date ?? false, + personaOrphaned: agent.persona_orphaned ?? false, + needsRestart: agent.needs_restart ?? false, + restartDiff: agent.restart_diff ?? [], + envVars: agent.env_vars ?? {}, + status: agent.status, + pid: agent.pid, + createdAt: agent.created_at, + updatedAt: agent.updated_at, + lastStartedAt: agent.last_started_at, + lastStoppedAt: agent.last_stopped_at, + lastExitCode: agent.last_exit_code, + lastError: agent.last_error, + lastErrorCode: agent.last_error_code ?? null, + logPath: agent.log_path, + startOnAppLaunch: agent.start_on_app_launch, + autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, + backend: agent.backend, + backendAgentId: agent.backend_agent_id, + respondTo: agent.respond_to ?? "owner-only", + respondToAllowlist: agent.respond_to_allowlist ?? [], + }; +} diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts index a9481429f95..bafe142323e 100644 --- a/desktop/src/shared/api/readOnlyRelayClient.ts +++ b/desktop/src/shared/api/readOnlyRelayClient.ts @@ -47,6 +47,7 @@ export class ReadOnlyRelayClient { private histories = new Map(); private publishes = new Map(); private generation = 0; + private pendingChallenge: string | null = null; private readonly relayUrl: string; @@ -55,8 +56,8 @@ export class ReadOnlyRelayClient { } async connect(): Promise { - if (this.wsId !== null) return; if (this.connectPromise) return this.connectPromise; + if (this.wsId !== null) return; const promise = this.openConnection(); this.connectPromise = promise; @@ -98,6 +99,7 @@ export class ReadOnlyRelayClient { this.onMessageChannel = null; this.connectPromise = null; + this.pendingChallenge = null; } async fetchEvents(filter: RelaySubscriptionFilter): Promise { @@ -135,15 +137,22 @@ export class ReadOnlyRelayClient { const generation = ++this.generation; this.onMessageChannel = new Channel((delivery) => { for (const message of toRelayFrames(delivery)) { - void this.handleWsMessage(message, generation); + void this.handleWsMessage(message, generation).catch(() => { + if (generation === this.generation) this.disconnect(); + }); } }); - this.wsId = await invoke("plugin:websocket|connect", { + const wsId = await invoke("plugin:websocket|connect", { url: this.relayUrl, onMessage: this.onMessageChannel, config: {}, }); + if (generation !== this.generation) { + void closeWebSocket(wsId, "observer connection cancelled"); + throw new Error("Observer relay connection cancelled."); + } + this.wsId = wsId; await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { @@ -158,6 +167,14 @@ export class ReadOnlyRelayClient { reject, timeout, }; + // Native delivery can beat resolution of the connect IPC command. + if (this.pendingChallenge !== null) { + const challenge = this.pendingChallenge; + this.pendingChallenge = null; + void this.handleAuthChallenge(challenge, generation).catch(() => { + if (generation === this.generation) this.disconnect(); + }); + } }); } @@ -235,6 +252,10 @@ export class ReadOnlyRelayClient { const [type, ...rest] = data; if (type === "AUTH" && typeof rest[0] === "string") { + if (this.wsId === null) { + this.pendingChallenge = rest[0]; + return; + } await this.handleAuthChallenge(rest[0], generation); return; } @@ -254,6 +275,16 @@ export class ReadOnlyRelayClient { ); return; } + if (type === "CLOSED" && typeof rest[0] === "string") { + const pending = this.histories.get(rest[0]); + if (pending) { + window.clearTimeout(pending.timeout); + this.histories.delete(rest[0]); + // Discard partial history. A later EOSE cannot turn failure into absence. + pending.reject(new Error("Observer relay history request was closed.")); + } + return; + } if (type === "EOSE" && typeof rest[0] === "string") { this.handleEose(rest[0]); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 65d5f7bb7aa..791a6333bb1 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1,3 +1,5 @@ +import { fromRawManagedAgent, type RawManagedAgent } from "./managedAgentWire"; +export { fromRawManagedAgent, type RawManagedAgent } from "./managedAgentWire"; import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import { activateRateLimit, @@ -16,7 +18,6 @@ import type { GetHomeFeedInput, HomeFeedResponse, ManagedAgent, - ManagedAgentBackend, RelayAgent, RelayMember, RelayMemberRole, @@ -110,53 +111,6 @@ type RawRelayAgent = { respond_to?: RelayAgent["respondTo"]; respond_to_allowlist?: string[]; }; -import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; -export type RawManagedAgent = { - pubkey: string; - name: string; - persona_id: string | null; - // Optional: pre-feature fixtures may omit it. The record's harness/runtime id. - runtime?: string | null; - team_id?: string | null; - relay_url: string; - acp_command: string; - agent_command: string; - agent_command_override?: string | null; - agent_args: string[]; - mcp_command: string; - turn_timeout_seconds: number; - idle_timeout_seconds: number | null; - max_turn_duration_seconds: number | null; - parallelism: number; - system_prompt: string | null; - avatar_url?: string | null; - model: string | null; - model_source?: ManagedAgent["modelSource"]; - provider: string | null; - persona_out_of_date: boolean; - persona_orphaned: boolean; - needs_restart: boolean; - restart_diff?: RawRestartDiffEntry[]; - env_vars?: Record; - status: ManagedAgent["status"]; - pid: number | null; - created_at: string; - updated_at: string; - last_started_at: string | null; - last_stopped_at: string | null; - last_exit_code: number | null; - last_error: string | null; - last_error_code: number | null; - log_path: string; - start_on_app_launch: boolean; - auto_restart_on_config_change?: boolean; - backend: ManagedAgentBackend; - backend_agent_id: string | null; - // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. - respond_to?: ManagedAgent["respondTo"]; - respond_to_allowlist?: string[]; -}; - type RawCreateManagedAgentResponse = { agent: RawManagedAgent; private_key_nsec: string; @@ -626,52 +580,6 @@ function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { }; } -export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { - return { - pubkey: agent.pubkey, - name: agent.name, - personaId: agent.persona_id, - runtime: agent.runtime ?? null, - teamId: agent.team_id ?? null, - relayUrl: agent.relay_url, - acpCommand: agent.acp_command, - agentCommand: agent.agent_command, - agentCommandOverride: agent.agent_command_override ?? null, - agentArgs: agent.agent_args, - mcpCommand: agent.mcp_command, - turnTimeoutSeconds: agent.turn_timeout_seconds, - idleTimeoutSeconds: agent.idle_timeout_seconds, - maxTurnDurationSeconds: agent.max_turn_duration_seconds, - parallelism: agent.parallelism, - systemPrompt: agent.system_prompt, - avatarUrl: agent.avatar_url ?? null, - model: agent.model, - modelSource: agent.model_source ?? null, - provider: agent.provider ?? null, - personaOutOfDate: agent.persona_out_of_date ?? false, - personaOrphaned: agent.persona_orphaned ?? false, - needsRestart: agent.needs_restart ?? false, - restartDiff: agent.restart_diff ?? [], - envVars: agent.env_vars ?? {}, - status: agent.status, - pid: agent.pid, - createdAt: agent.created_at, - updatedAt: agent.updated_at, - lastStartedAt: agent.last_started_at, - lastStoppedAt: agent.last_stopped_at, - lastExitCode: agent.last_exit_code, - lastError: agent.last_error, - lastErrorCode: agent.last_error_code ?? null, - logPath: agent.log_path, - startOnAppLaunch: agent.start_on_app_launch, - autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, - backend: agent.backend, - backendAgentId: agent.backend_agent_id, - respondTo: agent.respond_to ?? "owner-only", - respondToAllowlist: agent.respond_to_allowlist ?? [], - }; -} - export function fromRawAcpRuntimeCatalogEntry( entry: RawAcpRuntimeCatalogEntry, ): AcpRuntimeCatalogEntry { diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 9f77566da99..12d1ced43f2 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -28,10 +28,19 @@ export async function startManagedAgent( return fromRawManagedAgent(response); } -export async function stopManagedAgent(pubkey: string): Promise { - const response = await invokeTauri("stop_managed_agent", { - pubkey, - }); +export type SelectedAgentStop = { + pubkey: string; + selectedRunId?: string | null; + expectedRelayUrl?: string | null; +}; + +export async function stopManagedAgent( + input: string | SelectedAgentStop, +): Promise { + const response = await invokeTauri( + "stop_managed_agent", + typeof input === "string" ? { pubkey: input } : input, + ); return fromRawManagedAgent(response); } @@ -96,15 +105,25 @@ export async function startManagedAgentRuntime( export async function stopManagedAgentRuntime( pubkey: string, relayUrl: string, + selectedRunId?: string | null, ): Promise { - return invokeTauri("stop_managed_agent_runtime", { pubkey, relayUrl }); + return invokeTauri("stop_managed_agent_runtime", { + pubkey, + relayUrl, + selectedRunId, + }); } export async function restartManagedAgentRuntime( pubkey: string, relayUrl: string, + selectedRunId?: string | null, ): Promise { - return invokeTauri("restart_managed_agent_runtime", { pubkey, relayUrl }); + return invokeTauri("restart_managed_agent_runtime", { + pubkey, + relayUrl, + selectedRunId, + }); } export async function putManagedAgentRuntimeLifecycle( diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 7528998592d..f718d253ecf 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -283,18 +283,7 @@ export type ManagedAgentRuntimeLifecycle = | "failed" | "stopped"; -export type ManagedAgentRuntimeStatus = { - pubkey: string; - /** Exact submitted descriptor, present only on startup reconcile results. */ - requestedRelayUrl?: string; - /** Canonical, backend-owned pair identity component. Do not normalize in TS. */ - relayUrl: string; - localSetup: boolean; - lifecycle: ManagedAgentRuntimeLifecycle; - pid: number | null; - error: string | null; - logPath: string | null; -}; +export type { ManagedAgentRuntimeStatus } from "./managedAgentRuntimeTypes"; export type ManagedAgentBackend = | { type: "local" } @@ -303,6 +292,8 @@ export type ManagedAgentBackend = import type { RestartDiffEntry } from "./restartDiff"; export type { JsonValue, RestartChange, RestartDiffEntry } from "./restartDiff"; export type ManagedAgent = { + selectedRunId?: string | null; + selectedRelayUrl?: string | null; pubkey: string; name: string; personaId: string | null; diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index e9d4b1e8ec6..8965b1041aa 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -13,7 +13,6 @@ import { resolveMessageLinkRenderTarget, type ParsedMessageLink, } from "@/features/messages/lib/messageLink"; -import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; @@ -23,7 +22,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; -import { InlineChip } from "@/shared/ui/InlineChip"; +import { MarkdownMention } from "./markdown/MarkdownMention"; import { LinkPreviewList } from "@/shared/ui/link-preview-list"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; import { @@ -1578,48 +1577,9 @@ export function createMarkdownComponents( ul: ({ children }) => (
    {children}
), - mention: function MarkdownMention({ - children, - }: { - children?: React.ReactNode; - }) { - const { agentMentionPubkeysByName, mentionPubkeysByName } = - useMarkdownRuntime(); - const mentionText = String(children ?? ""); - const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase(); - const pubkey = mentionPubkeysByName?.[mentionName]; - const isAgentMention = - pubkey !== undefined && - agentMentionPubkeysByName?.[mentionName] === pubkey; - const mentionLabel = mentionText.replace(/^@/, ""); - // Only chips that actually open a profile get the clickable affordance. - // A mention whose pubkey didn't resolve stays a plain chip — a pointer - // cursor there promises a click that does nothing. - const opensProfile = interactive && pubkey !== undefined; - const mentionNode = ( - - {mentionLabel} - - ); - - return opensProfile ? ( - - {mentionNode} - - ) : ( - mentionNode - ); - }, + mention: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), emoji: ({ src, alt }: { src?: string; alt?: string }) => { const resolvedSrc = src ? rewriteRelayUrl(src) : src; if (!resolvedSrc) { diff --git a/desktop/src/shared/ui/markdown/MarkdownMention.tsx b/desktop/src/shared/ui/markdown/MarkdownMention.tsx new file mode 100644 index 00000000000..cef35b24b84 --- /dev/null +++ b/desktop/src/shared/ui/markdown/MarkdownMention.tsx @@ -0,0 +1,52 @@ +import type * as React from "react"; +import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { InlineChip } from "@/shared/ui/InlineChip"; +import { cn } from "@/shared/lib/cn"; +import { useMarkdownRuntime } from "./runtimeContext"; + +/** Exact-identity mention chip and its shared management provenance. */ +export function MarkdownMention({ + children, + interactive, +}: { + children?: React.ReactNode; + interactive: boolean; +}) { + const { agentMentionPubkeysByName, mentionPubkeysByName } = + useMarkdownRuntime(); + const mentionText = String(children ?? ""); + const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase(); + const pubkey = mentionPubkeysByName?.[mentionName]; + const isAgentMention = + pubkey !== undefined && agentMentionPubkeysByName?.[mentionName] === pubkey; + const mentionLabel = mentionText.replace(/^@/, ""); + // Only chips that actually open a profile get the clickable affordance. + // A mention whose pubkey didn't resolve stays a plain chip — a pointer + // cursor there promises a click that does nothing. + const opensProfile = interactive && pubkey !== undefined; + const mentionNode = ( + + {mentionLabel} + {isAgentMention ? : null} + + ); + + return opensProfile ? ( + + {mentionNode} + + ) : ( + mentionNode + ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9c12ebef4fe..f3a23f0fbda 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -94,6 +94,8 @@ type MockCommandAvailability = { }; export type MockManagedAgentSeed = { + /** Persisted legacy pin, not the workspace selected for Start. */ + relayUrl?: string; pubkey: string; name: string; avatarUrl?: string | null; @@ -906,6 +908,8 @@ type RawRelayAgent = { }; type RawManagedAgent = { + selected_relay_url?: string | null; + selected_run_id?: string | null; pubkey: string; name: string; persona_id: string | null; @@ -1788,6 +1792,12 @@ function cloneRelayAgent(agent: RawRelayAgent): RawRelayAgent { function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { return { + // Native build_managed_agent_summary selects the workspace pair even + // before first Start. A stored relay pin and a live run are NOT required. + selected_relay_url: /^[0-9a-f]{64}$/i.test(agent.pubkey) + ? activeMockRelayUrl(getConfig()) + : null, + selected_run_id: null, // The mock does not model native generation receipts. pubkey: agent.pubkey, name: agent.name, persona_id: agent.persona_id, @@ -2353,7 +2363,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { // Native serde always emits this key (`null` when unpinned) — the bridge // must mirror the wire shape, not omit the key. runtime: seed.runtime ?? null, - relay_url: DEFAULT_RELAY_WS_URL, + relay_url: seed.relayUrl ?? DEFAULT_RELAY_WS_URL, acp_command: "buzz-acp", agent_command: agentCommand, agent_args: agentArgs, @@ -4206,12 +4216,7 @@ function getRelayWsUrl(config: E2eConfig | undefined): string { * switch with `openDmDelayMs` / `sendMessageDelayMs` and prove the send * fails closed. */ -function assertExpectedRelayScope( - expectedRelayUrl: string | null | undefined, - config: E2eConfig | undefined, -): void { - const expected = expectedRelayUrl?.trim(); - if (!expected) return; +function activeMockRelayUrl(config: E2eConfig | undefined): string { let active: string | null = null; try { const activeId = window.localStorage.getItem("buzz-active-community-id"); @@ -4224,10 +4229,16 @@ function assertExpectedRelayScope( } catch { active = null; } - if ( - normalizeMockRelayUrl(active ?? getRelayWsUrl(config)) !== - normalizeMockRelayUrl(expected) - ) { + return normalizeMockRelayUrl(active ?? getRelayWsUrl(config)); +} + +function assertExpectedRelayScope( + expectedRelayUrl: string | null | undefined, + config: E2eConfig | undefined, +): void { + const expected = expectedRelayUrl?.trim(); + if (!expected) return; + if (activeMockRelayUrl(config) !== normalizeMockRelayUrl(expected)) { throw new Error( "active community changed before the message was submitted; not sent", ); @@ -9513,7 +9524,11 @@ async function handleStartManagedAgent( agent.pid = agent.pid ?? 42000 + mockManagedAgents.indexOf(agent); // The real command spawns a pair runtime keyed by the agent's effective // relay (`start_managed_agent_process`), so mirror that row here. - upsertMockManagedAgentRuntime(agent.pubkey, agent.relay_url, "ready"); + upsertMockManagedAgentRuntime( + agent.pubkey, + activeMockRelayUrl(config), + "ready", + ); } agent.updated_at = now; agent.last_started_at = now; diff --git a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts index de94bb230f7..2b5ccc81e3c 100644 --- a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts +++ b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts @@ -393,3 +393,99 @@ test.describe("agent lifecycle feedback screenshots", () => { await expect(saveButton).toBeEnabled(); }); }); + +for (const switchBeforeStart of [false, true]) { + test(`summary Start uses its emitted workspace scope, not the legacy pin (switch=${switchBeforeStart})`, async ({ + page, + }) => { + const pubkey = CASCADE_AGENT_A_PUBKEY; + const relay = "wss://clicked.example"; + await installMockBridge( + page, + { + personas: [ + { + id: CASCADE_PERSONA_ID, + displayName: "Scope Agent", + systemPrompt: "Test scope.", + }, + ], + managedAgents: [ + { + pubkey, + name: "Scope Agent", + personaId: CASCADE_PERSONA_ID, + status: "stopped", + relayUrl: "wss://legacy-pin.example", + }, + ], + }, + { relayWsUrl: relay }, + ); + await openAgentsView(page); + const summary = await page.evaluate(async (key) => { + const rows = (await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.( + "list_managed_agents", + {}, + )) as Array<{ + pubkey: string; + selected_relay_url: string; + selected_run_id: string | null; + }>; + return rows.find((row) => row.pubkey === key); + }, pubkey); + expect(summary?.selected_relay_url).toBe(relay); + expect(summary?.selected_run_id).toBeNull(); + if (switchBeforeStart) { + // Change native-side authority while retaining the clicked UI summary. + await page.evaluate(() => { + const id = localStorage.getItem("buzz-active-community-id"); + const communities = JSON.parse( + localStorage.getItem("buzz-communities") ?? "[]", + ); + for (const community of communities) { + if (community.id === id) community.relayUrl = "wss://other.example"; + } + localStorage.setItem("buzz-communities", JSON.stringify(communities)); + }); + } + await page.getByTestId(`agent-runtime-start-${pubkey}`).click(); + if (switchBeforeStart) { + await expect( + page.getByText(/active community changed/).first(), + ).toBeVisible(); + await expect( + page.getByTestId(`agent-runtime-active-${pubkey}`), + ).toHaveCount(0); + } else { + await expect( + page.getByTestId(`agent-runtime-active-${pubkey}`), + ).toBeVisible(); + } + const { starts, runtimes } = await page.evaluate(async () => ({ + starts: window.__BUZZ_E2E_COMMAND_LOG__?.filter( + (entry) => entry.command === "start_managed_agent", + ), + runtimes: await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.( + "list_managed_agent_runtimes", + {}, + ), + })); + expect(starts).toHaveLength(1); + expect(starts?.[0].payload).toMatchObject({ + pubkey, + expectedRelayUrl: relay, + }); + expect(runtimes).toEqual( + switchBeforeStart + ? [] + : [ + expect.objectContaining({ + pubkey, + relayUrl: relay, + lifecycle: "ready", + }), + ], + ); + }); +} diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 88985fedecd..deadbb62e2c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -386,11 +386,11 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as ); await expect(relayProvenanceMarker).toHaveAttribute( "aria-label", - "From another Buzz setup", + "Not managed on this device", ); await expect(relayProvenanceMarker).toHaveAttribute( "title", - "From another Buzz setup", + "Not managed on this device", ); await expect(relayProvenanceMarker).toBeVisible(); await expect(relayProvenanceMarker).toHaveText(""); @@ -470,7 +470,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as ).toHaveCount(0); await expect(remoteSidebarMarker).toHaveAttribute( "aria-label", - "From another Buzz setup", + "Not managed on this device", ); await expect(remoteSidebarMarker).toHaveText(""); await expect(remoteSidebarMarker.locator("svg")).toBeVisible(); diff --git a/docs/host-execution.md b/docs/host-execution.md new file mode 100644 index 00000000000..da676b37ebc --- /dev/null +++ b/docs/host-execution.md @@ -0,0 +1,309 @@ +# Host execution foundations (not yet an enabled remote feature) + +The owner-operated native Start outbox, receiver and Hosts picker are implemented +behind the **non-default `remote-start-preview` Cargo feature**. Default builds +advertise `accepts_start: false` and reject queuing. Even a preview build advertises +Start only with a successful same-owner/community receiver pump in the last 15s +and at least one destination-local provisioned, compatible configuration with an +available agent key (the same availability refusal as launch). A +runtime catalog entry alone is not launch capability. This is not a release or +physical-host/provider certification. + +Kinds 50001/50002 are admitted only through the owner's global transport with an +exact nondeleted registration. Commands are owner-signed, receipts host-signed; +host keys receive no general login privilege. Query/result gates remain owner-only +and additive migration 0042 excludes both ciphertext kinds from FTS (heap rewrite; +plan deployment maintenance). Private profile v3 carries only agent public key, +runtime catalog ID and opaque configuration revision. No source keys, environment, +workspace or files are transferred. + +## Native seams + +- `inspect_local_execution_config`: owner/community-scoped inspection of an agent + already provisioned on this Desktop. Returns only Rust catalog runtime ID and + a destination configuration digest; never a launch payload/key/environment. +- `execute_host_command`: verifies an owner-signed NIP-44 command addressed to this + host, reads the exact nondeleted registration using the **existing owner's** + selected-community authority, checks freshness, then runs the native transition. + No independent host login or owner credentials export is introduced. +- Start uses the existing launch machinery and destination configuration. Its + revision is rechecked against the actual resolved inputs at the spawn boundary. + Unknown/missing runtime or authentication, setup mode, provider-backed records, + unsupported mesh preflight, prior receipt or live destination peer fail closed. + There is no arbitrary received command/env/path or source-host loopback endpoint. + This does **not** yet provision a source-only agent onto a second Desktop. +- The Start operation's random ID governs the launcher `start_nonce`, durable + receipt `run_id`, authenticated lifecycle correlation and public ACP run ID. + Ordinary local starts get a fresh generation as before. Legacy receipts deserialize + with no generation and cannot establish selected-run Stop authority. + +## Durable semantics + +A secret-free journal is keyed by local owner plus canonical agent/community +placement. An OS file lock serializes controllers sharing the store. Atomic +restricted writes and directory sync persist intent **before** side effects. +The immutable signed command event ID and request are retained with outcomes. +Retries reuse the same event/operation, return the recorded observation and never +repeat launch. A crash after intent but before a recorded result is `unknown`, +not an invitation to spawn again. A different command reusing an operation ID is +rejected. Journal corruption/unreadability and retention saturation fail closed. +There is no automatic garbage collection or recovery override. + +The placement remains fenced through Stop and unknown outcomes. Ordinary/config- +driven starts consult that fence too. A stale Stop checks the tracked generation +under the transition locks **before** writing a new fence, so it neither signals +nor takes control of a successor. Other agent/community placements are untouched. + +`spawned` means a child was actually created, **not** listening, ready, a model turn +or conversational presence. The protocol reserves listening/ready observations; +the native executor currently records spawn only, not asynchronous receipt updates. +Results are host-signed, encrypted to the owner, and correlated to the exact signed +command, request, host registration and generation. Expired presence is never an +execution result. Authorization is rechecked even on retry. Historical bytes authenticate at their +signed timestamp, but the native transition checks actual wall-clock expiry after +immutable ledger replay and before creating any new intent or side effect. Future +timestamps are rejected. Expiration never creates a replacement operation. + +The native outbox uses restricted atomic writes, directory fsync and OS file locks. +It retains signed commands and receipt ciphertext through ACK loss/reconnect/restart. +Before retrying a durable receipt, the destination checks accepted history. If the +result never reached the relay and its envelope is at least 10 minutes old, it +re-signs the **same encrypted observation bytes and routing tags** with a fresh +transport timestamp and fsyncs before sending. Original command ID, operation, +run, outcome and observation time do not change. Recent retries reuse exact +signed bytes; accepted history of any earlier envelope resolves ACK loss without +creating another observation. Only a saved, authenticated result may be renewed; +this never executes an expired intent or opens a new ledger operation. Current +registration and unlocked owner/community authority are still required. + +Owner-private host registration/inventory and execution history reads use the +same native no-redirect client as Start publication. Their 15-second per-request +deadline includes response-body consumption (rate-limit admission wait is +separate). Redirects are errors, including 307/308: neither private filter bodies +nor authentication are sent to a redirect target. Unrelated query transports +retain their existing policy. + +Publication errors are recorded per entry, remain visible and retry independently; +a revoked old registration does not starve another operation. Corrupt bindings, +missing receipts and invalid/cyclic supersession chains fail closed. Transport ACK +means only relay acceptance. The app-scoped pump runs independently of the Hosts +page, and generation-matched public run presence supplies the actual location. + +`queue_host_start` without `new_attempt_after` always reuses the saved current +intent, even if completed or rejected. A genuinely new user intent supplies that +exact previous operation ID. It requires either its signed rejected outcome or a +host-signed `stopped` receipt correlated to an owner-signed Stop of that exact run. +Neither `root_exited`, missing presence nor expiry qualifies. A persisted +supersession chain selects the current intent without timestamp tie ambiguity. +Destination placement fences still revalidate admission; no Start bypasses a +successor/unknown placement fence. + +## Exact Stop: supported owned-work completion + +Native Stop signals only the retained selected-generation ACP owner and allows +90 seconds for nested cleanup. It never signals a reaped/recycled root or a newer +run. Deadline escalation is `unknown`, not successful Stop. The legacy PID-only +best-effort path remains separate. + +Root/group exit alone remains **`root_exited`**, blocking replacement. `stopped` +now additionally requires a verified agent-signed local owned-work proof for the +same agent, canonical community and existing launcher `start_nonce`, plus a +successfully reaped root. No binary-name, timing, goodbye/log, presence or exit-code +heuristic can produce this proof. The host subsequently issues the existing +host-signed encrypted execution Receipt for the immutable Stop command. + +### Existing Desktop Stop and explicit restart + +Existing agent, profile, sidebar and pair controls pass the clicked runtime nonce +and community to the same native selected-run execution/proof/ledger authority. +They do not look up a replacement generation at click execution time. Missing +nonce/community is unsupported; stale selections cannot signal a successor. +RootExited and Unknown return an error and keep replacement fenced. A repeated +ordinary Stop reuses its original immutable local request; a live successor +cannot be reported as stopped by replaying that earlier success. + +Summary/profile Start captures the selected community, and Restart retains that +same community across its Stop await. The summary producer selects the active +workspace pair even for an unstarted agent: selected community does not require +a live generation and is not the legacy record relay pin. Missing summary scope +fails closed at the control action, rather than falling back to current UI state. +The native Start command binds its owner and community before preflight, then +revalidates after suspension; a stale continuation cannot start in the newly +selected workspace. Fresh create-start likewise binds its entry owner/community +before mesh preflight, even though it has no prior summary selection. + +An explicit ordinary Start after confirmed exact Stop or definite rejected Start, +and the existing pair Restart action, enter the same ledger for a fresh generation. +Local recovery uses ordinary local pair/config/readiness semantics (including +legacy stored relay pins, setup-listener mode and custom runtimes), not remote +provisioning grants. Remote Start still requires its pinned relay, verified owner +attestation and advertised compatible runtime. Both paths recheck the exact +configuration revision at spawn. The captured local predecessor is rechecked +under the transition and OS journal locks; a concurrent/newer intent invalidates +it. Rejected means no child was created by that attempt: spawn failures before +child creation qualify, but post-spawn persistence failure, timeout and root exit +do not. Accepted/Unknown/RootExited/Spawned remain fenced, including after reopen; +old commands replay their immutable results. Automatic reconcile remains fenced. +No agent-configuration model rules changed. + +Spawn is not Ready: an immediate Stop during ACP pool initialization +can return RootExited, even when partial children were drained. It cannot certify +replacement; the actual tracer preserves this negative case rather than forcing +Stopped. Provider `!shutdown` behavior is unchanged. Config-edit, delete +and application cleanup retain their separate best-effort behavior and are not +certified Stop claims. No new run authority or provider control channel is added. + +### Minimal supported capability chain + +1. ACP installs SIGTERM/SIGINT handlers **before spawning**. The same sticky watch + cancels eager/lazy initialization and respawn/backoff; startup errors explicitly + drain partial pools. Stop cancels checked-out channel and heartbeat work before + joining it. Bounded abort/escalation never clears incomplete-child evidence. +2. `buzz-agent` advertises `_meta.buzzOwnedWorkShutdown: 1` at ACP initialize. + `_buzz/shutdown_v1` closes request admission, cancels sessions and joins all + session/new and prompt tasks, including partial initialization. It returns + `{v:1, ownedWorkStopped:true}` only when all lifetime MCP children completed. + EOF/broken output still performs cleanup, but cannot issue this acknowledgement. +3. MCP discovery of `_buzz_shutdown_v1` negotiates the supervisor-only capability + (underscore tools are hidden from the model). It closes shell admission, + cancels and drains shell owners, and returns exactly + `buzz.owned-work.stopped.v1` only when owned shell roots were reaped and their + groups disappeared. Dropped/aborted/unobserved shell work is sticky uncertainty. +4. The agent retains each actual MCP child through an rmcp `Transport` adapter: + explicit work acknowledgement **and** successful child reap are both required. + rmcp 1.8's default transport masks timeout-kill/nonzero exits, so waiting for its + `cancel()` alone was insufficient. Failed initialization, restart, borrowed + clients, noncooperative tools and forced exits cannot certify completion. +5. ACP requires the negotiated agent acknowledgement and successful agent reap. + A process-lifetime incomplete-child count includes failed/aborted respawns. + Only zero incomplete children permits writing the final local proof, using the + agent key already entrusted to this runtime. No new run ID/key/kill authority + or relay kind is introduced. Native stamps `BUZZ_STOP_RECEIPT_PATH` beside the + runtime log (`.stop-.json`); ACP strips this variable from agent + children. The file is create-new, mode0600, synced, signature-checked and bounded + to4096 bytes. Missing, altered or wrong-scope files cannot authorize replacement. + +**Supported boundary and failure model:** this is trusted cooperative +`buzz-acp → buzz-agent → buzz-dev-mcp → shell process-group` execution on Unix, +not hostile-code containment. Ordinary shell children/grandchildren in their +owned group are included, even if ignoring TERM (the tool owner kills and reaps +them). Deliberately detached/daemonized work, external services/jobs created by +commands, arbitrary third-party tools, compromised executors/agent keys and +OS-level unobservable workloads are outside the supported capability contract. +Do not present it as universal descendant or remote-job termination. Unsupported +servers do not silently inherit support from their name or exit status; they +remain `root_exited`/`unknown`. Windows lacks the required observation here and +fails closed. Lifetime uncertainty persists even if a later replacement child +shuts down cleanly. A native restart without a retained child also stays unknown. + +Move's approved meaning remains: confirmed selected-run Stop, then a fresh runtime +session for the same agent at the destination, **no automatic file/workspace +transfer**. Unknown/root-only outcomes block replacement; unrelated placements +are preserved. Start and Move transport remain default-off. + +### Repeatable real-process check (fixture relay/provider) + +```sh +cargo build --locked -p buzz-acp -p buzz-agent -p buzz-dev-mcp +export BUZZ_STOP_CHAIN_BIN_DIR="$PWD/target/debug" # use actual CARGO_TARGET_DIR if set +# Optional: a fresh directory for process-tree snapshots and per-owner logs. +export BUZZ_STOP_CHAIN_ARTIFACTS="$(mktemp -d)" +# Source-only native test; this does not test packaged mesh sidecars. +export TAURI_CONFIG='{"bundle":{"externalBin":[]}}' +cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml \ + selected_generation_process_chain -- --ignored --nocapture +``` + +Requires Unix and Node. The test starts real `buzz-acp`, `buzz-agent`, and +`buzz-dev-mcp` binaries, uses the production native selected-generation guard and +termination seam, and observes real shell/grandchild PIDs in independently owned +process groups. A same-identity peer on another fixture relay remains running +through selected Stop, stale-generation rejection and retry-after-reap rejection. +Every peer process is then stopped by its own owner. Fixture services bind only +loopback, accept test auth, and supply a canned OpenAI tool call. The test clears +inherited configuration and uses only a public deterministic test key. + +This is **not** a Desktop UI test, signed command receiver/outbox test, real LLM +provider run or second physical machine. It verifies the signed supported +completion proof and rejects different-community/generation proof reuse. +The source-level native journal tests separately pin crash/ACK-loss deduplication +and the rule that only confirmed exact Stop permits replacement. + +## Selected-run Move (preview) + +`queue_host_move` accepts the selected source registration/run, destination +registration and its provisioned agent/runtime/revision. The native owner checks +both current registrations, authenticated active source and destination presence, +existing destination instances and signed compatible provisioning before saving. +Presence is only a selection/preflight hint, never a Stop result. + +A Move dependency lives in the existing locked owner/community Start outbox. +A domain-separated owner signature binds the exact encrypted Stop command and +reserved destination Start template (including its predecessor). Only Stop is +initially publishable. The app-scoped receiver handles both actions through the +same native command/ledger seam, without giving hosts additional authority. +Only a verified `stopped` receipt for that exact command, agent, host, community +and run releases the reserved destination Start. Both registrations and destination +configuration are checked again; execution rechecks configuration at spawn. +The Start TTL begins at release. The release and immutable outbox entry are saved +atomically before publication. Crash/reconnect/ACK-loss retries do not mint a run. + +Root-only exit, rejection, timeout, missing presence/receipt and Unknown block +replacement. The destination is reserved while waiting; other source runs and +agent/host/community placements are untouched. A saved Move to another destination +cannot silently supersede this intent. Once the source is confirmed stopped, +configuration drift may be corrected with an explicit retry using current +provisioning. If destination execution rejects Start, the ordinary explicit +`new_attempt_after` recovery creates a new destination attempt; unknown Start +still cannot be replaced. There is never an automatic source restart. + +Hosts exposes exact active-run selection, disabled destinations with reasons, +and a fresh-session/no-files/keys/configuration-transfer disclosure. Persisted +progress remains visible after source presence disappears. Spawned is not Ready +and a matching new run/host is displayed only when actually observed. + +## Validation and integration gates + +The opt-in debug-only `remote-start-tracer` feature builds a separate +`host-start-tracer` executable. It uses two local native Wry executors, fresh +synthetic keys, independent keyring/HOME/app-data scopes, the actual native +queue/pump/transition, and real buzz-acp/buzz-agent binaries. It requires an +isolated loopback relay and a newly initialized fixture directory. See source +`desktop/src-tauri/src/host_start_tracer.rs`; never use live credentials or a +shared production profile. Fixture provider configuration is explicitly synthetic; +a spawned process and public live run label are not an inference-turn proof or +two physical machines. On macOS the tracer pins each executor to a native keychain +file under its fixture HOME; it does not change the user's default keychain or +search list. + +The local two-executor tracer was exercised on 2026-08-31: native destination +spawn, a verified host-signed `spawned` receipt, and matching public run/host label +were observed. A restarted source reused the same immutable operation. The +fixture initialized real buzz-agent ACP sessions but did not request inference. +Cleanup reaped only the fixture's tracked root, not a certified Stop. + +The 2026-08-31 Move tracer additionally completed confirmed selected-source Stop +followed by destination spawn with the same agent and matching fresh run/host. +The unrelated source peer survived Move and then completed its own ordinary Stop. +An old source token was rejected at the destination. The expanded tracer also +exercised ordinary Start after confirmed Stop, waited for that exact new run's +signed live presence, then used ordinary Restart and finally its own confirmed +Stop. Retrying the earlier successful Stop could not report the successor stopped. +Both executors completed with the required result files; fixture cleanup was not +used as certification evidence. A debug tracer failure is checked from its result +and log as well as exit code (the tracer uses the returning native event loop). + +Remaining gates include real-provider and second-physical-host validation, +actual Hosts native UI capture, authenticated asynchronous lifecycle receipt +updates beyond spawned, and destination provisioning UX. No final mesh-enabled +DMG or combined feature certification is implied by this slice. + +The same debug tracer has `move-init`, `move-source`, and `move-destination` +roles (fresh fixture directory, isolated loopback relay). It provisions synthetic +identities on both executors, starts the source plus an unrelated peer, queues +Move through native IPC, and records either `move-success.json` (signed native +outcomes + matching new run/host + surviving peer) or `move-blocked.json`. +It never fabricates Stopped. Build all three workload binaries from the certified +Stop source before running. Neither fixture cleanup nor a blocked trace is a +successful Move certification. Native Hosts UI and real-provider work remain +separate evidence requirements. diff --git a/migrations/0041_host_fts.sql b/migrations/0041_host_fts.sql new file mode 100644 index 00000000000..059d8b559e6 --- /dev/null +++ b/migrations/0041_host_fts.sql @@ -0,0 +1,26 @@ +-- Private host metadata must not be indexed. Same heap-rewrite operational +-- cost as 0033: schedule a maintenance window for large databases. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 50000 THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/migrations/0042_host_execution_fts.sql b/migrations/0042_host_execution_fts.sql new file mode 100644 index 00000000000..73781c50498 --- /dev/null +++ b/migrations/0042_host_execution_fts.sql @@ -0,0 +1,26 @@ +-- Private execution commands and receipts must not be indexed. Same heap-rewrite operational +-- cost as 0033: schedule a maintenance window for large databases. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind IN (50001, 50002) THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index 54566103335..23e088a986e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -219,9 +219,9 @@ CREATE TABLE events ( -- Privacy: encrypted/private routing wrappers and p-gated membership notices -- must never be discoverable through NIP-50 full-text search. NULL tsvector -- never matches `@@`. - -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). + -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033 + 0040 + 0041 + 0042). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200, 50000, 50001, 50002) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, diff --git a/scripts/fixtures/stop-owner-chain.mjs b/scripts/fixtures/stop-owner-chain.mjs new file mode 100644 index 00000000000..773c6e48309 --- /dev/null +++ b/scripts/fixtures/stop-owner-chain.mjs @@ -0,0 +1,64 @@ +// Isolated protocol fixture ONLY: accepts NIP-42 without verification and serves +// canned OpenAI tool calls. No real relay, provider, credentials or paid work. +// Run only via the ignored native selected_generation_process_chain test. +import http from 'node:http'; +import { createHash } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; + +function frame(value) { + const body = Buffer.from(JSON.stringify(value)); + const header = body.length < 126 ? Buffer.from([0x81, body.length]) : Buffer.from([0x81, 126, body.length >> 8, body.length & 255]); + return Buffer.concat([header, body]); +} +const server = http.createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) { + body += chunk; + if (body.length > 4_000_000) { res.writeHead(413).end(); return; } + } + let result = []; + if (req.url.includes('chat/completions')) { + const parsed = JSON.parse(body); + const tool = parsed.tools?.find(t => t.function?.name.endsWith('__shell'))?.function.name; + if (!tool) { res.writeHead(400).end('shell tool missing'); return; } + result = {id:'fixture', object:'chat.completion', model:'fixture', choices:[{ + index:0, message:{role:'assistant', content:null, tool_calls:[{ + id:'fixture-shell', type:'function', function:{name:tool, arguments:JSON.stringify({ + command:'echo $$ > shell.pid; echo $PPID > mcp.pid; sleep 600 & echo $! > grandchild.pid; wait', + timeout_ms:600000 + })} + }]}, finish_reason:'tool_calls' + }]}; + } else if (req.url === '/events') { + result = {accepted:true}; + } + res.writeHead(200, {'content-type':'application/json'}).end(JSON.stringify(result)); +}); +server.on('upgrade', (req, socket) => { + const accept = createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); + socket.write(frame(['AUTH','stop-chain-fixture'])); + let buffer = Buffer.alloc(0); + socket.on('error', () => {}); + socket.on('data', data => { + buffer = Buffer.concat([buffer, data]); + while (buffer.length >= 2) { + const opcode = buffer[0] & 15; + let len = buffer[1] & 127; + let offset = 2; + if (len === 126) { if (buffer.length < 4) return; len = buffer.readUInt16BE(2); offset = 4; } + if (len === 127 || !(buffer[1] & 128)) { socket.destroy(); return; } + if (buffer.length < offset + 4 + len) return; + const mask = buffer.subarray(offset, offset + 4); offset += 4; + const payload = Buffer.from(buffer.subarray(offset, offset + len)); + buffer = buffer.subarray(offset + len); + for (let i = 0; i < len; i++) payload[i] ^= mask[i % 4]; + if (opcode === 8) { socket.end(Buffer.from([0x88, 0])); return; } + if (opcode !== 1) continue; + const msg = JSON.parse(payload.toString()); + if (msg[0] === 'AUTH' || msg[0] === 'EVENT') socket.write(frame(['OK', msg[1].id, true, 'fixture'])); + if (msg[0] === 'REQ') socket.write(frame(['EOSE',msg[1]])); + } + }); +}); +server.listen(0, '127.0.0.1', () => writeFileSync(process.argv[2], String(server.address().port)));