From 60dacc53a73f7e5bcbd568365072f5b8fde25635 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 11:26:03 -0400 Subject: [PATCH 01/51] feat(desktop): list private known Desktops Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_profile.rs | 152 +++++++++ crates/buzz-core/src/kind.rs | 5 + crates/buzz-core/src/lib.rs | 2 + crates/buzz-db/src/runtime/migration.rs | 53 ++- crates/buzz-relay/src/api/bridge.rs | 6 +- .../src/api/desktop_profile_postgres_tests.rs | 167 ++++++++++ crates/buzz-relay/src/handlers/event.rs | 28 +- crates/buzz-relay/src/handlers/ingest.rs | 9 +- .../src/commands/desktop_profiles.rs | 155 +++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 2 + desktop/src/app/App.tsx | 6 +- .../src/features/agents/desktopList.test.mjs | 306 ++++++++++++++++++ desktop/src/features/agents/desktopList.ts | 69 ++++ desktop/src/features/agents/ui/AgentsView.tsx | 2 + .../src/features/agents/ui/KnownDesktops.tsx | 113 +++++++ desktop/src/shared/api/relayClientSession.ts | 4 + migrations/0045_desktop_profile_fts.sql | 26 ++ schema/schema.sql | 2 +- 19 files changed, 1093 insertions(+), 16 deletions(-) create mode 100644 crates/buzz-core/src/desktop_profile.rs create mode 100644 crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs create mode 100644 desktop/src-tauri/src/commands/desktop_profiles.rs create mode 100644 desktop/src/features/agents/desktopList.test.mjs create mode 100644 desktop/src/features/agents/desktopList.ts create mode 100644 desktop/src/features/agents/ui/KnownDesktops.tsx create mode 100644 migrations/0045_desktop_profile_fts.sql diff --git a/crates/buzz-core/src/desktop_profile.rs b/crates/buzz-core/src/desktop_profile.rs new file mode 100644 index 00000000000..697808c4677 --- /dev/null +++ b/crates/buzz-core/src/desktop_profile.rs @@ -0,0 +1,152 @@ +//! Owner-private display-only Desktop identity; never execution authority. +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::kind::KIND_DESKTOP_PROFILE; + +/// Minimal encrypted profile. IDs are installation-local within an owner/community. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DesktopProfile { + /// Format version. + pub v: u8, + /// Canonical relay URL, bound inside the ciphertext. + pub community: String, + /// Opaque random coordinate, not an agent key. + pub id: String, + /// Generated display name, never a hostname. + pub name: String, +} + +/// Validate the public envelope without decrypting private content. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + let tags: Vec<_> = event.tags.iter().map(|tag| tag.as_slice()).collect(); + if event.kind.as_u16() as u32 != KIND_DESKTOP_PROFILE + || event.created_at.as_secs() > 253_402_300_799 + || !(132..=2048).contains(&event.content.len()) + || tags.len() != 1 + || tags[0].len() != 2 + || tags[0][0] != "d" + || !valid_id(&tags[0][1]) + { + return Err("invalid Desktop profile envelope"); + } + Ok(()) +} + +fn valid_id(id: &str) -> bool { + id.len() == 32 + && id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +impl DesktopProfile { + /// Construct a generated, non-identifying name for a random coordinate. + pub fn new(community: String, id: String) -> Result { + if !valid_id(&id) || community.is_empty() || community.len() > 512 { + return Err("invalid Desktop profile coordinate"); + } + Ok(Self { + v: 1, + name: format!("Desktop {}", &id[..8]), + community, + id, + }) + } + + /// Encrypt to the owner and sign the exact replaceable event. + pub fn sign(&self, keys: &Keys) -> Result { + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(self).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + EventBuilder::new(Kind::Custom(KIND_DESKTOP_PROFILE as u16), content) + .tag(Tag::identifier(&self.id)) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) + } + + /// Verify hash/signature, owner, scope and exact payload before display. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop profile signature")?; + if event.pubkey != keys.public_key() { + return Err("foreign Desktop profile".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop profile decryption failed")?; + let profile: Self = + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop profile")?; + if profile + != Self::new( + community.to_owned(), + event.tags.identifier().unwrap_or_default().to_owned(), + )? + { + return Err("invalid Desktop profile payload or community".into()); + } + Ok(profile) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_profile_roundtrip_and_untrusted_inputs() { + let owner = Keys::generate(); + let profile = DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(); + let event = profile.sign(&owner).unwrap(); + assert_eq!( + DesktopProfile::read(&event, &owner, &profile.community).unwrap(), + profile + ); + assert!(!event.content.contains(&profile.name)); + assert!(DesktopProfile::read(&event, &Keys::generate(), &profile.community).is_err()); + assert!(DesktopProfile::read(&event, &owner, "wss://two.example").is_err()); + let mut tampered = event.clone(); + tampered.content.push('x'); + assert!(DesktopProfile::read(&tampered, &owner, &profile.community).is_err()); + for field in ["v", "id", "name", "community", "extra"] { + let mut payload = serde_json::to_value(&profile).unwrap(); + payload[field] = serde_json::json!("wrong"); + let encrypted = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + payload.to_string(), + nip44::Version::V2, + ) + .unwrap(); + let invalid = EventBuilder::new(event.kind, encrypted) + .tag(Tag::identifier(&profile.id)) + .sign_with_keys(&owner) + .unwrap(); + assert!( + DesktopProfile::read(&invalid, &owner, &profile.community).is_err(), + "{field}" + ); + } + for tags in [ + vec![], + vec![Tag::identifier("bad")], + vec![Tag::identifier(&profile.id), Tag::identifier(&profile.id)], + ] { + let invalid = EventBuilder::new(event.kind, &event.content) + .tags(tags) + .sign_with_keys(&owner) + .unwrap(); + assert!(validate_envelope(&invalid).is_err()); + } + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_PROFILE)); + assert!(crate::kind::is_parameterized_replaceable( + KIND_DESKTOP_PROFILE + )); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4e1ab1c7f5e..bb8be1a2819 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -117,6 +117,9 @@ 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; +/// Owner-private encrypted Desktop identity/name, keyed by installation coordinate. +pub const KIND_DESKTOP_PROFILE: u32 = 30180; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -130,6 +133,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_EVENT_REMINDER, KIND_PUSH_LEASE, KIND_PRIVATE_MANAGED_AGENT, + KIND_DESKTOP_PROFILE, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -659,6 +663,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MANAGED_AGENT, KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, + KIND_DESKTOP_PROFILE, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3b..fe94c0f356a 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// Owner-private Desktop display profiles. +pub mod desktop_profile; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 59015125042..8cc2b3cd08d 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 44); + assert_eq!(migrations.len(), 45); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -911,7 +911,7 @@ mod postgres_tests { 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)")); + .contains("kind IN (1059, 30179, 30180, 30300, 30350, 30622, 44100, 44101, 44200)")); // Public push-gateway authority is intentionally deployment-global and // durable: immediate revocation and hostile-relay admission cannot be @@ -2386,7 +2386,12 @@ mod postgres_tests { .await .expect("insert community"); - for (marker, kind) in [(1_u8, 1_i32), (2_u8, 30_350_i32), (3_u8, 30_179_i32)] { + for (marker, kind) in [ + (1_u8, 1_i32), + (2_u8, 30_350_i32), + (3_u8, 30_179_i32), + (4_u8, 30_180_i32), + ] { sqlx::query( "INSERT INTO events \ (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at) \ @@ -2413,7 +2418,10 @@ mod postgres_tests { .fetch_all(&pool) .await .expect("read pre-push search behavior"); - assert_eq!(before, vec![(1, true), (30_179, true), (30_350, true)]); + assert_eq!( + before, + vec![(1, true), (30_179, true), (30_180, true), (30_350, true)] + ); // 0014 fixes 30350 only. A brownfield database that stopped here still // tokenized kind:30179 ciphertext — the gap 0033 closes. @@ -2430,9 +2438,26 @@ mod postgres_tests { .expect("read pre-0033 search behavior"); assert_eq!( pre_0033, - vec![(1, Some(true)), (30_179, Some(true)), (30_350, None)] + vec![ + (1, Some(true)), + (30_179, Some(true)), + (30_180, Some(true)), + (30_350, None) + ] ); + run_migrations_through(&pool, 44) + .await + .expect("apply through 44"); + let desktop_indexed: bool = sqlx::query_scalar( + "SELECT search_tsv @@ plainto_tsquery('simple', 'needle') \ + FROM events WHERE kind = 30180", + ) + .fetch_one(&pool) + .await + .expect("read pre-0045 Desktop search behavior"); + assert!(desktop_indexed, "upgrade fixture must exercise legacy FTS"); + run_migrations(&pool) .await .expect("apply remaining migrations to populated database"); @@ -2443,7 +2468,23 @@ mod postgres_tests { .fetch_all(&pool) .await .expect("read post-upgrade search behavior"); - assert_eq!(after, vec![(1, Some(true)), (30_179, None), (30_350, None)]); + assert_eq!( + after, + vec![ + (1, Some(true)), + (30_179, None), + (30_180, None), + (30_350, None) + ] + ); + let gin_exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'events' \ + AND indexname = 'idx_events_search_tsv' AND indexdef LIKE '%USING gin%')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(gin_exists, "upgrade must restore the search GIN index"); } #[tokio::test] diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610de..816673219ef 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3839,7 +3839,7 @@ mod postgres_tests { /// - Redis pool points at the local dev instance for the admission check. /// /// Returns `None` when local Postgres is not reachable. - async fn bridge_handler_test_state() -> Option> { + pub(super) async fn bridge_handler_test_state() -> Option> { let mut config = crate::config::Config::from_env().ok()?; config.database_url = crate::test_support::database_url(); // Use the real local Redis so enforce_http_admission can pass. @@ -4239,3 +4239,7 @@ mod postgres_tests { ); } } + +#[cfg(test)] +#[path = "desktop_profile_postgres_tests.rs"] +mod desktop_profile_postgres_tests; diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs new file mode 100644 index 00000000000..38c8d8af3b2 --- /dev/null +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -0,0 +1,167 @@ +//! Private Desktop profiles through the production HTTP and WebSocket paths. +use super::postgres_tests::bridge_handler_test_state; +use super::*; +use axum::{body::Body, http::Request}; +use buzz_core::kind::KIND_DESKTOP_PROFILE; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use serde_json::json; +use tower::ServiceExt; + +async fn post( + state: &Arc, + host: &str, + path: &str, + keys: &Keys, + body: Value, + signed: bool, +) -> (StatusCode, Value) { + let mut request = Request::builder() + .method("POST") + .uri(path) + .header("host", host); + if signed { + let proof = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", &format!("https://{host}{path}")]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + request = request.header( + "authorization", + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&proof).unwrap()) + ), + ); + } else { + request = request.header("x-pubkey", keys.public_key().to_hex()); + } + let response = crate::router::build_router(state.clone()) + .oneshot( + request + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1_048_576) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + let mut frames = vec![]; + while let Ok(frame) = rx.try_recv() { + let axum::extract::ws::Message::Text(text) = frame else { + panic!("text frame") + }; + frames.push(serde_json::from_str(&text).unwrap()); + } + frames +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_profile_authenticated_owner_query_and_private_storage() { + let mut state = bridge_handler_test_state() + .await + .expect("test infrastructure"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true; + let host = format!("desktop-read-{}.example", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let tenant = TenantContext::resolved(community, &host); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let profile = buzz_core::desktop_profile::DesktopProfile::new( + format!("wss://{host}"), + uuid::Uuid::new_v4().simple().to_string(), + ) + .unwrap(); + let event = profile.sign(&owner).unwrap(); + let (status, result) = post(&state, &host, "/events", &owner, json!(event), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true, "{result}"); + // Match Desktop's actual bounded owner+kind inventory and exact-coordinate probe. + let own = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "limit":100}]); + let exact = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "#d":[profile.id], "limit":1}]); + for filters in [&own, &exact] { + let (status, rows) = post(&state, &host, "/query", &owner, filters.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows.as_array().unwrap().len(), 1); + assert_eq!(rows[0]["id"], event.id.to_hex()); + let (status, result) = + post(&state, &host, "/query", &outsider, filters.clone(), true).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); + let (status, result) = post(&state, &host, "/query", &owner, filters.clone(), false).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{result}"); + } + // Known IDs cannot grant an authenticated outsider read access either. + let known = json!([{"ids":[event.id.to_hex()], "kinds":[KIND_DESKTOP_PROFILE,1]}]); + let (status, rows) = post(&state, &host, "/query", &outsider, known, true).await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows, json!([])); + let other_host = format!("other-{host}"); + state + .db + .ensure_configured_community(&other_host) + .await + .unwrap(); + let (status, rows) = post(&state, &other_host, "/query", &owner, own.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows, json!([])); + // Inspect the generated column: searching for plaintext in ciphertext proves nothing. + let mut tx = state.db.begin_event_write_transaction().await.unwrap(); + let indexed: bool = sqlx::query_scalar( + "SELECT search_tsv IS NOT NULL FROM events WHERE id = $1 AND community_id = $2", + ) + .bind(event.id.to_bytes().as_slice()) + .bind(community.as_uuid()) + .fetch_one(&mut *tx) + .await + .unwrap(); + tx.rollback().await.unwrap(); + assert!(!indexed, "private ciphertext must not enter FTS"); + // WS REQ is the frontend transport; bind its real authenticated owner path. + for who in [&owner, &outsider] { + let (mut conn, mut rx) = crate::connection::tests::test_conn_with_auth( + crate::connection::AuthState::Authenticated(buzz_auth::AuthContext { + pubkey: who.public_key(), + scopes: buzz_auth::Scope::all_known(), + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }), + ); + Arc::get_mut(&mut conn).unwrap().tenant = tenant.clone(); + crate::handlers::req::handle_req( + "desktops".into(), + serde_json::from_value(own.clone()).unwrap(), + vec![], + conn, + state.clone(), + ) + .await; + let frames = drain(&mut rx); + let rows: Vec<_> = frames.iter().filter(|frame| frame[0] == "EVENT").collect(); + if who.public_key() == owner.public_key() { + assert_eq!(rows.len(), 1, "{frames:?}"); + assert_eq!(rows[0][2]["id"], event.id.to_hex()); + assert!(frames.iter().any(|frame| frame[0] == "EOSE"), "{frames:?}"); + } else { + assert!(rows.is_empty(), "{frames:?}"); + assert!( + frames.iter().any(|frame| frame[0] == "CLOSED"), + "{frames:?}" + ); + } + } +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 66a8ff9e7c0..5ed5f1037e2 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -2207,6 +2207,15 @@ mod tests { #[tokio::test] async fn author_only_reminder_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_EVENT_REMINDER).await; + } + + #[tokio::test] + async fn desktop_profile_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_PROFILE).await; + } + + async fn assert_author_only_fanout(kind: u32) { let state = test_state().await; let author_keys = Keys::generate(); @@ -2216,12 +2225,9 @@ mod tests { // KIND_EVENT_REMINDER (30300) is in AUTHOR_ONLY_KINDS and is stored // globally (channel_id = None), so the gate must apply independent // of any channel-membership check. - let reminder = EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_EVENT_REMINDER as u16), - "{}", - ) - .sign_with_keys(&author_keys) - .expect("sign reminder"); + let reminder = EventBuilder::new(Kind::Custom(kind as u16), "{}") + .sign_with_keys(&author_keys) + .expect("sign reminder"); let stored = StoredEvent::new(reminder, None); let author_conn = register_conn(&state, Some(author_pk)); @@ -2233,6 +2239,16 @@ mod tests { (other_conn, "o".to_string()), (unauthed_conn, "u".to_string()), ]; + // Even an authenticated owner subscription in another tenant is denied. + assert!(filter_fanout_by_access( + &state, + buzz_core::CommunityId::from_uuid(Uuid::new_v4()), + &stored, + matches.clone(), + None, + ) + .await + .is_empty()); let out = filter_fanout_by_access( &state, buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee1d0312be9..613544df9d2 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -10,6 +10,7 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use buzz_auth::Scope; +use buzz_core::kind::KIND_DESKTOP_PROFILE; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, @@ -436,7 +437,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 | KIND_DESKTOP_PROFILE => 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 @@ -657,6 +658,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_TEAM | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT + | KIND_DESKTOP_PROFILE | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -2781,6 +2783,11 @@ async fn ingest_event_inner( } } + if kind_u32 == KIND_DESKTOP_PROFILE { + buzz_core::desktop_profile::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_EVENT_REMINDER { validate_event_reminder(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs new file mode 100644 index 00000000000..3b3017da71b --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_profiles.rs @@ -0,0 +1,155 @@ +//! Durable read-only Desktop profiles, separate from persona publication queues. +use buzz_core_pkg::desktop_profile::DesktopProfile; +use nostr::{Event, JsonUtil}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior}; +use serde_json::{json, Value}; +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; +use crate::managed_agents::retention::{active_retention_scope, open_retention_db, RetentionScope}; + +fn scope( + app: &AppHandle, + state: &AppState, + owner: &str, + community: &str, +) -> Result { + let scope = active_retention_scope(app, state)?; + if scope.owner_keys.public_key().to_hex() != owner + || scope.relay_url.trim_end_matches('/') != community + { + return Err("Desktop profile scope changed".into()); + } + Ok(scope) +} + +fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result { + // SQLite serializes concurrent startup/open requests across processes. The ID + // and exact ciphertext/signature commit together, before any network write. + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS desktop_profile ( + slot INTEGER PRIMARY KEY CHECK(slot = 1), raw TEXT NOT NULL);", + ) + .map_err(|e| e.to_string())?; + let saved: Option = tx + .query_row( + "SELECT raw FROM desktop_profile WHERE slot = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + let raw = match saved { + Some(saved) => saved, + None => { + let profile = DesktopProfile::new( + scope.relay_url.trim_end_matches('/').to_owned(), + uuid::Uuid::new_v4().simple().to_string(), + )?; + let raw = profile.sign(&scope.owner_keys)?.as_json(); + tx.execute("INSERT INTO desktop_profile VALUES (1, ?1)", [&raw]) + .map_err(|e| e.to_string())?; + raw + } + }; + let event = Event::from_json(&raw).map_err(|_| "invalid saved Desktop profile")?; + DesktopProfile::read( + &event, + &scope.owner_keys, + scope.relay_url.trim_end_matches('/'), + )?; + tx.commit().map_err(|e| e.to_string())?; + Ok(json!({ "event": event })) +} + +/// Prepare or reload the identical owner/community-local installation profile. +#[tauri::command] +pub fn prepare_desktop_profile( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + prepare(&mut open_retention_db(&scope.db_path)?, &scope) +} + +/// Authenticate and decrypt a bounded relay result before exposing any row to UI. +#[tauri::command] +pub fn read_desktop_profiles( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, + events: Vec, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + if events.len() > 100 { + return Err("too many Desktop profiles".into()); + } + let rows: Result, String> = events.iter().map(|event| { + let profile = DesktopProfile::read(event, &scope.owner_keys, &community)?; + Ok(json!({ "id": profile.id, "name": profile.name, "updated": event.created_at.as_secs() })) + }).collect(); + Ok(json!(rows?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::retention::scoped_retention_db_path; + + #[test] + fn durable_identity_exact_retry_without_rewrites() { + let dir = tempfile::tempdir().unwrap(); + let owner_keys = nostr::Keys::generate(); + let scope = RetentionScope { + db_path: dir.path().join("one.db"), + relay_url: "wss://one.example".into(), + owner_keys, + }; + let first = prepare(&mut open_retention_db(&scope.db_path).unwrap(), &scope).unwrap(); + let mut reopened = open_retention_db(&scope.db_path).unwrap(); + assert_eq!(prepare(&mut reopened, &scope).unwrap(), first); + // No mutable ACK state: a confirmed or failed publish leaves the same + // signed record available for exact retry, without another native write. + let accepted = prepare(&mut reopened, &scope).unwrap(); + assert_eq!(accepted, first); + assert_eq!(reopened.total_changes(), 0, "no repeated native writes"); + let other = RetentionScope { + db_path: dir.path().join("two.db"), + relay_url: scope.relay_url.clone(), + owner_keys: scope.owner_keys.clone(), + }; + let second = prepare(&mut open_retention_db(&other.db_path).unwrap(), &other).unwrap(); + assert_ne!(first["event"]["tags"], second["event"]["tags"]); + let a = scoped_retention_db_path( + dir.path(), + &scope.relay_url, + &scope.owner_keys.public_key().to_hex(), + ); + assert_ne!( + a, + scoped_retention_db_path( + dir.path(), + "wss://two.example", + &scope.owner_keys.public_key().to_hex() + ) + ); + assert_ne!( + a, + scoped_retention_db_path( + dir.path(), + &scope.relay_url, + &nostr::Keys::generate().public_key().to_hex() + ) + ); + reopened + .execute("UPDATE desktop_profile SET raw = 'corrupt'", []) + .unwrap(); + assert!(prepare(&mut reopened, &scope).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c8184a01031..3e3c38a3e8c 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod channel_templates; mod channel_window; mod channels; mod clipboard; +mod desktop_profiles; mod dms; mod engrams; mod export_util; @@ -92,6 +93,7 @@ pub use channel_templates::*; pub use channel_window::*; pub use channels::*; pub use clipboard::*; +pub use desktop_profiles::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12082a2a82e..65fe1aaa4d7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -552,6 +552,8 @@ pub fn run() { transfer_builderlab_community, title_bar_double_click, get_identity, + prepare_desktop_profile, + read_desktop_profiles, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index da0fbf65c49..90cec7b8e74 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,3 +1,4 @@ +import { DesktopListStartup } from "@/features/agents/ui/KnownDesktops"; import { isTauri } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -261,7 +262,10 @@ function CommunityQueryProvider({ }, [queryClient]); return ( - {children} + + + {children} + ); } diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs new file mode 100644 index 00000000000..4ffa141e0d2 --- /dev/null +++ b/desktop/src/features/agents/desktopList.test.mjs @@ -0,0 +1,306 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { refreshDesktopList } from "./desktopList.ts"; +import { DesktopListView } from "./ui/KnownDesktops.tsx"; + +const scope = { owner: "owner-a", community: "wss://a.example" }; +const first = { + id: "signed-a", + pubkey: scope.owner, + kind: 30180, + tags: [["d", "desktop-a"]], +}; +const second = { ...first, id: "signed-b", tags: [["d", "desktop-b"]] }; +function fixture() { + const events = new Map([[second.id, second]]); + const calls = []; + let epoch = 0; + const ipc = async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push(command); + if (command === "prepare_desktop_profile") return { event: first }; + if (command === "read_desktop_profiles") + return args.events.map((event) => ({ + id: event.tags[0][1], + name: event.tags[0][1], + updated: 100, + })); + }; + const relay = { + getSessionEpoch: () => epoch, + fetchEvents: async (filter) => { + assert.deepEqual(filter.authors, [scope.owner]); + assert.deepEqual(filter.kinds, [30180]); + assert.ok(filter.limit <= 100); + return [...events.values()].filter( + (event) => !filter["#d"] || filter["#d"].includes(event.tags[0][1]), + ); + }, + publishEvent: async (event) => { + calls.push(event); + events.set(event.id, event); + }, + }; + return { + ipc, + relay, + calls, + events, + switchScope: () => { + epoch++; + }, + }; +} + +test("two same-owner Desktops retained without presence; startup doesn't rewrite", async () => { + const f = fixture(); + const list = await refreshDesktopList(scope, () => true, f.ipc, f.relay); + assert.equal(list.rows.length, 2); + assert.equal(list.local, "desktop-a"); + assert.equal(list.warning, ""); + assert.deepEqual( + await refreshDesktopList(scope, () => true, f.ipc, f.relay), + list, + ); + assert.equal(f.calls.filter((call) => call === first).length, 1); +}); + +test("ACK loss retries the exact object, while disconnected entries remain listed", async () => { + const f = fixture(); + f.relay.publishEvent = async (event) => { + f.calls.push(event); + throw Error("lost ACK"); + }; + for (let retry = 0; retry < 2; retry++) { + const list = await refreshDesktopList(scope, () => true, f.ipc, f.relay); + assert.equal(list.rows.length, 1); + assert.ok(list.warning); + } + assert.deepEqual( + f.calls.filter((call) => typeof call === "object"), + [first, first], + ); + assert.ok(!f.calls.includes("acknowledge_desktop_profile")); +}); + +test("owner/community switch fences prepared bytes and late ACKs", async () => { + for (const boundary of ["prepare_desktop_profile", "publish"]) { + const f = fixture(); + const ipc = async (command, args) => { + const result = await f.ipc(command, args); + if (command === boundary) f.switchScope(); + return result; + }; + if (boundary === "publish") + f.relay.publishEvent = async () => f.switchScope(); + await assert.rejects( + refreshDesktopList(scope, () => true, ipc, f.relay), + /scope changed/, + ); + assert.ok(!f.calls.includes("acknowledge_desktop_profile")); + assert.ok(!f.calls.includes(first)); + } +}); + +test("denied coordinate read is not absence, invalid reader result is not an empty list", async () => { + const f = fixture(); + const fetch = f.relay.fetchEvents; + f.relay.fetchEvents = async (filter) => { + if (filter["#d"]) throw Error("denied"); + return fetch(filter); + }; + assert.ok( + (await refreshDesktopList(scope, () => true, f.ipc, f.relay)).warning, + ); + assert.ok(!f.calls.includes(first)); + await assert.rejects( + refreshDesktopList( + scope, + () => true, + async () => { + throw Error("invalid"); + }, + f.relay, + ), + ); +}); + +test("rendered list distinguishes current, partial, unavailable and empty without online claims", () => { + const list = { + rows: [{ id: "a", name: "Desktop a", updated: 100 }], + local: "a", + warning: "", + partial: true, + }; + const render = (data, error = false) => + renderToStaticMarkup( + React.createElement(DesktopListView, { + list: data, + error, + loading: false, + refresh() {}, + }), + ); + const html = render(list, true); + assert.match(html, /This Desktop/); + assert.match(html, /Profile updated/); + assert.match(html, /Partial list/); + assert.match(html, /unavailable/); + assert.match(html, /Desktop a/); + assert.doesNotMatch(html, /No Desktop profiles found/); + assert.match( + render({ ...list, rows: [], partial: false }), + /No Desktop profiles found/, + ); + assert.doesNotMatch(html, /Last heard|Online|Offline/); +}); + +test("mounted cache clears both scopes, fences late reads and retains rows on failure", async () => { + const { JSDOM } = await import("jsdom"); + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { CommunitiesProvider, useCommunities } = await import( + "../communities/useCommunities.tsx" + ); + const { KnownDesktops, DesktopListStartup } = await import( + "./ui/KnownDesktops.tsx" + ); + const { relayClient } = await import("../../shared/api/relayClient.ts"); + const communities = ["a", "b"].map((id) => ({ + id, + name: id, + relayUrl: `wss://${id}.example`, + })); + localStorage.setItem("buzz-communities", JSON.stringify(communities)); + localStorage.setItem("buzz-active-community-id", "a"); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(["identity"], { pubkey: "owner-a" }); + let controls; + let fail = false; + let hold = false; + let release; + let current; + const originalFetch = relayClient.fetchEvents; + const originalPublish = relayClient.publishEvent; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "prepare_desktop_profile") { + current = { + ...first, + id: `${args.owner}-${args.community}`, + tags: [["d", args.owner]], + }; + return { event: current }; + } + assert.equal(command, "read_desktop_profiles"); + return args.events.map((event) => ({ + id: event.id, + name: event.id, + updated: 100, + })); + }, + }; + relayClient.fetchEvents = async (filter) => { + if (fail) throw Error("unavailable"); + if (filter["#d"]) return [current]; + const rows = [current]; + if (hold) { + hold = false; + return new Promise((resolve) => { + release = () => resolve(rows); + }); + } + return rows; + }; + relayClient.publishEvent = async () => { + throw Error("unexpected rewrite"); + }; + function Screen() { + controls = useCommunities(); + return React.createElement( + React.Fragment, + null, + React.createElement(DesktopListStartup), + React.createElement(KnownDesktops), + ); + } + const root = createRoot(document.getElementById("root")); + const settle = () => + React.act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + const text = () => document.body.textContent; + try { + await React.act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Screen), + ), + ), + ), + ); + await settle(); + assert.match(text(), /owner-a-wss:\/\/a.example/); + hold = true; + await React.act(async () => { + void client.refetchQueries({ queryKey: ["desktop-profiles"] }); + }); + assert.equal(typeof release, "function"); + await React.act(async () => { + client.setQueryData(["identity"], { pubkey: "owner-b" }); + }); + await settle(); + assert.doesNotMatch(text(), /owner-a/); + await React.act(async () => release()); + await settle(); + assert.match(text(), /owner-b-wss:\/\/a.example/); + assert.doesNotMatch(text(), /owner-a/); + assert.equal( + client.getQueryData(["desktop-profiles", "owner-a", "wss://a.example"]), + undefined, + ); + fail = true; + await React.act(async () => { + await client.refetchQueries({ queryKey: ["desktop-profiles"] }); + }); + await settle(); + assert.match(text(), /unavailable/); + assert.match(text(), /owner-b-wss:\/\/a.example/); + await React.act(async () => controls.switchCommunity("b")); + await settle(); + assert.doesNotMatch(text(), /owner-b-wss:\/\/a.example/); + fail = false; + await React.act(async () => { + await client.refetchQueries({ queryKey: ["desktop-profiles"] }); + }); + await settle(); + assert.match(text(), /owner-b-wss:\/\/b.example/); + } finally { + await React.act(async () => root.unmount()); + client.clear(); + relayClient.fetchEvents = originalFetch; + relayClient.publishEvent = originalPublish; + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/desktopList.ts b/desktop/src/features/agents/desktopList.ts new file mode 100644 index 00000000000..da3f0cea6d7 --- /dev/null +++ b/desktop/src/features/agents/desktopList.ts @@ -0,0 +1,69 @@ +import { invoke } from "@tauri-apps/api/core"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; + +export type DesktopRow = { id: string; name: string; updated: number }; +export type DesktopScope = { owner: string; community: string }; +export type DesktopList = { + rows: DesktopRow[]; + local: string; + partial: boolean; + warning: string; +}; +const KIND = 30180; +const LIMIT = 100; + +/** Every continuation belongs to this mounted owner/community, including late ACKs. */ +export async function refreshDesktopList( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +): Promise { + const epoch = relay.getSessionEpoch(); + const check = () => { + if (!active() || epoch !== relay.getSessionEpoch()) + throw new Error("Desktop scope changed"); + }; + const wait = async (work: Promise) => { + const result = await work; + check(); + return result; + }; + const read = (events: RelayEvent[]) => + wait(ipc("read_desktop_profiles", { ...scope, events })); + const filter = { kinds: [KIND], authors: [scope.owner] }; + check(); + let local = ""; + let warning = ""; + try { + const { event } = await wait( + ipc<{ event: RelayEvent }>("prepare_desktop_profile", scope), + ); + const [profile] = await read([event]); + local = profile.id; + // A bounded inventory is never evidence that this coordinate is missing. + const head = await wait( + relay.fetchEvents({ ...filter, "#d": [local], limit: 1 }), + ); + await read(head); + if (head.length && head[0].id !== event.id) + throw new Error("Desktop profile differs on relay"); + if (!head.length) + await wait( + relay.publishEvent( + event, + "Desktop publish timed out", + "Desktop publish failed", + ), + ); + } catch { + check(); + warning = + "This Desktop profile could not be synchronized. Retry to publish it."; + } + // Listing is independent of local publication and never uses scalar presence. + const events = await wait(relay.fetchEvents({ ...filter, limit: LIMIT })); + const rows = await read(events); + return { rows, local, partial: events.length === LIMIT, warning }; +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a31fec44c49..1212a8b8826 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,3 +1,4 @@ +import { KnownDesktops } from "./KnownDesktops"; import * as React from "react"; import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; import { @@ -217,6 +218,7 @@ export function AgentsView() { description="Set up and manage your agents." title="Agents" /> +
void; +}; + +function useDesktopList() { + const owner = useIdentityQuery().data?.pubkey; + const { activeCommunity } = useCommunities(); + const community = activeCommunity?.relayUrl + .trim() + .replace(/^http/, "ws") + .replace(/\/+$/, ""); + return useQuery({ + queryKey: ["desktop-profiles", owner, community], + enabled: !!owner && !!community, + queryFn: ({ signal }) => { + if (!owner || !community) throw new Error("Desktop scope unavailable"); + return refreshDesktopList({ owner, community }, () => !signal.aborted); + }, + // Last observer removal cancels and evicts decrypted data, including on an + // account switch within the same community. No previous-key placeholder. + gcTime: 0, + retry: false, + refetchOnWindowFocus: false, + }); +} + +/** Startup and Agents share the existing owner/community query cache. */ +export function DesktopListStartup() { + const { refetch } = useDesktopList(); + useEffect( + () => + relayClient.subscribeToReconnects(() => { + void refetch(); + }), + [refetch], + ); + return null; +} + +export function KnownDesktops() { + const query = useDesktopList(); + return ( + { + void query.refetch(); + }} + /> + ); +} + +export function DesktopListView({ list, loading, error, refresh }: View) { + return ( +
+
+

Known Desktops

+ +
+

+ Private to you. Saved profiles do not indicate whether a Desktop is + online or ready to run agents. +

+ {loading &&

Loading Desktop profiles…

} + {error && ( +

+ Desktop profiles unavailable. Previously loaded profiles are retained. +

+ )} + {list?.warning &&

{list.warning}

} + {list?.partial && ( +

Partial list: showing up to 100 profiles.

+ )} + {list && !list.rows.length && !error &&

No Desktop profiles found.

} +
    + {list?.rows.map((row) => ( +
  • + {row.name} + {row.id === list.local && " · This Desktop"} +
    + Profile updated{" "} + +
    +
  • + ))} +
+
+ ); +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 95bcec79700..93384a40741 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -111,6 +111,10 @@ export class RelayClient { setVisibleChannelId(id: string | null) { this.visibleChannelId = id; } + /** Scope epoch changes before a community or identity transport is replaced. */ + getSessionEpoch() { + return this.sessionEpoch; + } disconnect() { const error = new Error("Relay disconnected for community switch."); diff --git a/migrations/0045_desktop_profile_fts.sql b/migrations/0045_desktop_profile_fts.sql new file mode 100644 index 00000000000..31599368771 --- /dev/null +++ b/migrations/0045_desktop_profile_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop profiles must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +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 = 30180 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 09508125622..59147e4204e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). 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, 30180, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From ff5b04ff10f6859595bd95e6f6eebe412e4a403e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 11:26:03 -0400 Subject: [PATCH 02/51] fix(relay): admit delayed immutable Desktop profiles Signed-off-by: Logan Johnson --- .../src/api/desktop_profile_postgres_tests.rs | 91 +++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 49 +++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs index 38c8d8af3b2..922c4bb0226 100644 --- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -165,3 +165,94 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() { } } } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn aged_desktop_profile_retries_through_production_ingest_without_resigning() { + let mut state = bridge_handler_test_state() + .await + .expect("test infrastructure"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true; + let host = format!("desktop-retry-{}.example", uuid::Uuid::new_v4().simple()); + state.db.ensure_configured_community(&host).await.unwrap(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let profile = buzz_core::desktop_profile::DesktopProfile::new( + format!("wss://{host}"), + uuid::Uuid::new_v4().simple().to_string(), + ) + .unwrap(); + let prepared = profile.sign(&owner).unwrap(); + // Model bytes committed during yesterday's offline first launch. Neither + // the first submission nor its duplicate is re-dated or re-signed below. + let now = Timestamp::now().as_secs(); + let aged = EventBuilder::new(prepared.kind, &prepared.content) + .tags(prepared.tags.iter().cloned()) + .custom_created_at(Timestamp::from(now - 86_400)) + .sign_with_keys(&owner) + .unwrap(); + let raw = json!(aged); + for _ in 0..2 { + let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true, "{result}"); + let (status, rows) = post( + &state, + &host, + "/query", + &owner, + json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "ids":[aged.id.to_hex()]}]), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows.as_array().unwrap().len(), 1); + for field in [ + "id", + "pubkey", + "kind", + "created_at", + "tags", + "content", + "sig", + ] { + assert_eq!(rows[0][field], raw[field], "stored {field} changed"); + } + let stored: nostr::Event = serde_json::from_value(rows[0].clone()).unwrap(); + assert_eq!( + buzz_core::desktop_profile::DesktopProfile::read( + &stored, + &owner, + &format!("wss://{host}") + ) + .unwrap(), + profile + ); + } + // The age exception grants no signer authority and bypasses no envelope or + // signature checks. These calls use the real HTTP -> shared ingest path. + let (status, result) = post(&state, &host, "/events", &outsider, raw.clone(), true).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); + let mut corrupt = raw.clone(); + corrupt["content"] = json!(format!("{}x", aged.content)); + let (status, result) = post(&state, &host, "/events", &owner, corrupt, true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + let invalid = EventBuilder::new(aged.kind, &aged.content) + .tag(Tag::identifier("invalid-coordinate")) + .custom_created_at(aged.created_at) + .sign_with_keys(&owner) + .unwrap(); + let future = EventBuilder::new(aged.kind, &aged.content) + .tags(aged.tags.iter().cloned()) + .custom_created_at(Timestamp::from(now + 86_400)) + .sign_with_keys(&owner) + .unwrap(); + let ordinary = EventBuilder::text_note("old ordinary event") + .custom_created_at(aged.created_at) + .sign_with_keys(&owner) + .unwrap(); + for rejected in [invalid, future, ordinary] { + let (status, result) = post(&state, &host, "/events", &owner, json!(rejected), true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 613544df9d2..13c781613fb 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2169,6 +2169,17 @@ pub async fn ingest_event( result } +// Profiles are durable display records, not freshness signals. A Desktop may +// first publish its immutable signed record long after an offline startup. +// Only their past-age bound is waived; future drift and all other admission +// checks still apply. Observation/presence kinds must retain their own window. +fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool { + const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900; + event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS) + && (kind == KIND_DESKTOP_PROFILE + || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) +} + async fn ingest_event_inner( state: &Arc, tracer: &Arc, @@ -2233,10 +2244,8 @@ async fn ingest_event_inner( } let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone()); - const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes let now = chrono::Utc::now().timestamp(); - let event_ts = event.created_at.as_secs() as i64; - if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { + if !timestamp_within_ingest_window(kind_u32, event.created_at.as_secs(), now as u64) { return Err(IngestError::Rejected( "invalid: event timestamp too far from server time".into(), )); @@ -3317,6 +3326,40 @@ mod postgres_tests { )); } + #[test] + fn immutable_profile_age_exception_is_past_only_and_kind_specific() { + let now = 1_800_000_000; + // Include the next observation kind explicitly: freshness is not profile age. + for kind in [ + KIND_DESKTOP_PROFILE, + 30181, + KIND_PROFILE, + KIND_EVENT_REMINDER, + 1, + ] { + for (timestamp, ordinary, profile) in [ + (0, false, true), + (now - 86_400, false, true), + (now - 901, false, true), + (now - 900, true, true), + (now, true, true), + (now + 900, true, true), + (now + 901, false, false), + (u64::MAX, false, false), + ] { + assert_eq!( + timestamp_within_ingest_window(kind, timestamp, now), + if kind == KIND_DESKTOP_PROFILE { + profile + } else { + ordinary + }, + "kind={kind} timestamp={timestamp}" + ); + } + } + } + #[test] fn huddle_backing_channel_lookup_outage_is_internal() { let error = sqlx::Error::Io(std::io::Error::other("database unavailable")); From 271fefd62900b7024fd1a7050aaa7d7d4f4e6cd6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 11:43:12 -0400 Subject: [PATCH 03/51] test(desktop): match unsigned relay ingest drift constant Signed-off-by: Logan Johnson --- desktop/src/shared/api/relayReconnectReplay.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 7c7d96f44c1..117731df914 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -112,7 +112,7 @@ test("channel replay lookback stays coupled to relay and DB source constants", a assert.match( ingest, new RegExp( - `MAX_TIMESTAMP_DRIFT_SECS: i64 = ${RELAY_INGEST_FUTURE_TOLERANCE_SECS}`, + `MAX_TIMESTAMP_DRIFT_SECS: u64 = ${RELAY_INGEST_FUTURE_TOLERANCE_SECS}`, ), ); assert.match( From e5812f053b951fdccd187d21236dd85538e8cd03 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 10:51:04 -0400 Subject: [PATCH 04/51] feat(desktop): show private per-Desktop last-heard observations Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_observation.rs | 123 ++++++++++++++++++ crates/buzz-core/src/desktop_profile.rs | 9 +- crates/buzz-core/src/kind.rs | 5 + crates/buzz-core/src/lib.rs | 1 + crates/buzz-db/src/runtime/migration.rs | 30 ++++- .../src/api/desktop_profile_postgres_tests.rs | 28 +++- crates/buzz-relay/src/handlers/event.rs | 5 + crates/buzz-relay/src/handlers/ingest.rs | 10 +- .../src/commands/desktop_profiles.rs | 56 +++++++- desktop/src-tauri/src/lib.rs | 2 + .../src/features/agents/desktopList.test.mjs | 50 ++++++- .../agents/desktopObservations.test.mjs | 103 +++++++++++++++ .../features/agents/desktopObservations.ts | 82 ++++++++++++ .../src/features/agents/ui/KnownDesktops.tsx | 77 +++++++++-- migrations/0046_desktop_observation_fts.sql | 26 ++++ schema/schema.sql | 2 +- 16 files changed, 580 insertions(+), 29 deletions(-) create mode 100644 crates/buzz-core/src/desktop_observation.rs create mode 100644 desktop/src/features/agents/desktopObservations.test.mjs create mode 100644 desktop/src/features/agents/desktopObservations.ts create mode 100644 migrations/0046_desktop_observation_fts.sql diff --git a/crates/buzz-core/src/desktop_observation.rs b/crates/buzz-core/src/desktop_observation.rs new file mode 100644 index 00000000000..21ba206280a --- /dev/null +++ b/crates/buzz-core/src/desktop_observation.rs @@ -0,0 +1,123 @@ +//! Owner-private, advisory Desktop observations; never agent readiness or placement. +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_OBSERVATION}; + +/// A pulse for one local profile. The signed event timestamp is the observed time. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DesktopObservation { + /// Format version. + pub v: u8, + /// Canonical community, encrypted along with the coordinate. + pub community: String, + /// Stable Desktop profile coordinate, not an execution credential. + pub id: String, +} + +/// Validate the bounded public envelope without decrypting it. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_OBSERVATION) +} + +impl DesktopObservation { + /// Observe a profile belonging to this local Desktop. + pub fn new(profile: DesktopProfile) -> Self { + Self { + v: 1, + community: profile.community, + id: profile.id, + } + } + + /// Encrypt and sign a fresh observation, without rewriting the durable profile. + pub fn sign(&self, keys: &Keys) -> Result { + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(self).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + EventBuilder::new(Kind::Custom(KIND_DESKTOP_OBSERVATION as u16), content) + .tag(Tag::identifier(&self.id)) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) + } + + /// Authenticate and decrypt an observation before displaying its timestamp. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop observation signature")?; + if event.pubkey != keys.public_key() { + return Err("foreign Desktop observation".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop observation decryption failed")?; + let observation: Self = + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop observation")?; + let expected = Self::new(DesktopProfile::new( + community.to_owned(), + event.tags.identifier().unwrap_or_default().to_owned(), + )?); + if observation != expected { + return Err("Desktop observation scope mismatch".into()); + } + Ok(observation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn observation_is_private_scoped_and_distinct_from_profile() { + let keys = Keys::generate(); + let profile = DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(); + let saved = profile.sign(&keys).unwrap(); + let observation = DesktopObservation::new(profile); + let event = observation.sign(&keys).unwrap(); + assert_eq!( + DesktopObservation::read(&event, &keys, &observation.community).unwrap(), + observation + ); + assert!(DesktopObservation::read(&event, &keys, "wss://two.example").is_err()); + assert!( + DesktopObservation::read(&event, &Keys::generate(), &observation.community).is_err() + ); + assert!(DesktopObservation::read(&saved, &keys, &observation.community).is_err()); + assert!(DesktopProfile::read(&event, &keys, &observation.community).is_err()); + let forged_author = EventBuilder::new(event.kind, &event.content) + .tags(event.tags.clone()) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(DesktopObservation::read(&forged_author, &keys, &observation.community).is_err()); + let mut tampered = event.clone(); + tampered.created_at = nostr::Timestamp::from(1); + assert!(DesktopObservation::read(&tampered, &keys, &observation.community).is_err()); + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_OBSERVATION)); + for field in ["v", "community", "id", "extra"] { + let mut payload = serde_json::to_value(&observation).unwrap(); + payload[field] = serde_json::json!("invalid"); + let ciphertext = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + payload.to_string(), + nip44::Version::V2, + ) + .unwrap(); + let invalid = EventBuilder::new(event.kind, ciphertext) + .tags(event.tags.clone()) + .sign_with_keys(&keys) + .unwrap(); + assert!( + DesktopObservation::read(&invalid, &keys, &observation.community).is_err(), + "{field}" + ); + } + } +} diff --git a/crates/buzz-core/src/desktop_profile.rs b/crates/buzz-core/src/desktop_profile.rs index 697808c4677..5b08a438b3a 100644 --- a/crates/buzz-core/src/desktop_profile.rs +++ b/crates/buzz-core/src/desktop_profile.rs @@ -20,8 +20,15 @@ pub struct DesktopProfile { /// Validate the public envelope without decrypting private content. pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + validate_private_desktop_envelope(event, KIND_DESKTOP_PROFILE) +} + +pub(crate) fn validate_private_desktop_envelope( + event: &Event, + kind: u32, +) -> Result<(), &'static str> { let tags: Vec<_> = event.tags.iter().map(|tag| tag.as_slice()).collect(); - if event.kind.as_u16() as u32 != KIND_DESKTOP_PROFILE + if event.kind.as_u16() as u32 != kind || event.created_at.as_secs() > 253_402_300_799 || !(132..=2048).contains(&event.content.len()) || tags.len() != 1 diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index bb8be1a2819..1e5bfd43d6e 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -120,6 +120,9 @@ pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; /// Owner-private encrypted Desktop identity/name, keyed by installation coordinate. pub const KIND_DESKTOP_PROFILE: u32 = 30180; +/// Owner-private, per-Desktop last-heard observation; not online or readiness. +pub const KIND_DESKTOP_OBSERVATION: u32 = 30181; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -134,6 +137,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_PUSH_LEASE, KIND_PRIVATE_MANAGED_AGENT, KIND_DESKTOP_PROFILE, + KIND_DESKTOP_OBSERVATION, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -664,6 +668,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, KIND_DESKTOP_PROFILE, + KIND_DESKTOP_OBSERVATION, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index fe94c0f356a..2105e4a15a7 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +pub mod desktop_observation; /// Owner-private Desktop display profiles. pub mod desktop_profile; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 8cc2b3cd08d..5a0d2a64150 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 45); + assert_eq!(migrations.len(), 46); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -910,8 +910,9 @@ mod postgres_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, 30180, 30300, 30350, 30622, 44100, 44101, 44200)")); + assert!(include_str!("../../../../schema/schema.sql").contains( + "kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200)" + )); // Public push-gateway authority is intentionally deployment-global and // durable: immediate revocation and hostile-relay admission cannot be @@ -2391,6 +2392,7 @@ mod postgres_tests { (2_u8, 30_350_i32), (3_u8, 30_179_i32), (4_u8, 30_180_i32), + (5_u8, 30_181_i32), ] { sqlx::query( "INSERT INTO events \ @@ -2420,7 +2422,13 @@ mod postgres_tests { .expect("read pre-push search behavior"); assert_eq!( before, - vec![(1, true), (30_179, true), (30_180, true), (30_350, true)] + vec![ + (1, true), + (30_179, true), + (30_180, true), + (30_181, true), + (30_350, true) + ] ); // 0014 fixes 30350 only. A brownfield database that stopped here still @@ -2442,6 +2450,7 @@ mod postgres_tests { (1, Some(true)), (30_179, Some(true)), (30_180, Some(true)), + (30_181, Some(true)), (30_350, None) ] ); @@ -2457,6 +2466,18 @@ mod postgres_tests { .await .expect("read pre-0045 Desktop search behavior"); assert!(desktop_indexed, "upgrade fixture must exercise legacy FTS"); + run_migrations_through(&pool, 45) + .await + .expect("apply through 45"); + let observation_indexed: bool = + sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30181") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + observation_indexed, + "0046 must change brownfield observation FTS" + ); run_migrations(&pool) .await @@ -2474,6 +2495,7 @@ mod postgres_tests { (1, Some(true)), (30_179, None), (30_180, None), + (30_181, None), (30_350, None) ] ); diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs index 922c4bb0226..5d863666832 100644 --- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -2,7 +2,7 @@ use super::postgres_tests::bridge_handler_test_state; use super::*; use axum::{body::Body, http::Request}; -use buzz_core::kind::KIND_DESKTOP_PROFILE; +use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; use tower::ServiceExt; @@ -67,6 +67,16 @@ fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Ve #[tokio::test] #[ignore = "requires Postgres"] async fn desktop_profile_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_PROFILE).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_observation_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_OBSERVATION).await; +} + +async fn assert_private_desktop(kind: u32) { let mut state = bridge_handler_test_state() .await .expect("test infrastructure"); @@ -86,13 +96,21 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() { uuid::Uuid::new_v4().simple().to_string(), ) .unwrap(); - let event = profile.sign(&owner).unwrap(); + let id = profile.id.clone(); + let event = if kind == KIND_DESKTOP_PROFILE { + profile.sign(&owner).unwrap() + } else { + buzz_core::desktop_observation::DesktopObservation::new(profile) + .sign(&owner) + .unwrap() + }; let (status, result) = post(&state, &host, "/events", &owner, json!(event), true).await; assert_eq!(status, StatusCode::OK, "{result}"); assert_eq!(result["accepted"], true, "{result}"); // Match Desktop's actual bounded owner+kind inventory and exact-coordinate probe. - let own = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "limit":100}]); - let exact = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "#d":[profile.id], "limit":1}]); + let own = json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "limit":100}]); + let exact = + json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "#d":[id], "limit":1}]); for filters in [&own, &exact] { let (status, rows) = post(&state, &host, "/query", &owner, filters.clone(), true).await; assert_eq!(status, StatusCode::OK, "{rows}"); @@ -105,7 +123,7 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() { assert_eq!(status, StatusCode::UNAUTHORIZED, "{result}"); } // Known IDs cannot grant an authenticated outsider read access either. - let known = json!([{"ids":[event.id.to_hex()], "kinds":[KIND_DESKTOP_PROFILE,1]}]); + let known = json!([{"ids":[event.id.to_hex()], "kinds":[kind,1]}]); let (status, rows) = post(&state, &host, "/query", &outsider, known, true).await; assert_eq!(status, StatusCode::OK, "{rows}"); assert_eq!(rows, json!([])); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 5ed5f1037e2..b2f86ec357c 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -2215,6 +2215,11 @@ mod tests { assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_PROFILE).await; } + #[tokio::test] + async fn desktop_observation_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await; + } + async fn assert_author_only_fanout(kind: u32) { let state = test_state().await; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 13c781613fb..f60a76fd4a8 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -10,7 +10,6 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use buzz_auth::Scope; -use buzz_core::kind::KIND_DESKTOP_PROFILE; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, @@ -37,6 +36,7 @@ use buzz_core::kind::{ RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; +use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; @@ -437,7 +437,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 | KIND_DESKTOP_PROFILE => Ok(Scope::UsersWrite), + KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION => 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 @@ -659,6 +659,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT | KIND_DESKTOP_PROFILE + | KIND_DESKTOP_OBSERVATION | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -2792,6 +2793,11 @@ async fn ingest_event_inner( } } + if kind_u32 == KIND_DESKTOP_OBSERVATION { + buzz_core::desktop_observation::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_DESKTOP_PROFILE { buzz_core::desktop_profile::validate_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs index 3b3017da71b..d3438d71c51 100644 --- a/desktop/src-tauri/src/commands/desktop_profiles.rs +++ b/desktop/src-tauri/src/commands/desktop_profiles.rs @@ -1,5 +1,5 @@ //! Durable read-only Desktop profiles, separate from persona publication queues. -use buzz_core_pkg::desktop_profile::DesktopProfile; +use buzz_core_pkg::{desktop_observation::DesktopObservation, desktop_profile::DesktopProfile}; use nostr::{Event, JsonUtil}; use rusqlite::{Connection, OptionalExtension, TransactionBehavior}; use serde_json::{json, Value}; @@ -97,6 +97,50 @@ pub fn read_desktop_profiles( Ok(json!(rows?)) } +/// Sign a fresh pulse for the persisted local profile, never an arbitrary host ID. +#[tauri::command] +pub fn prepare_desktop_observation( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + Ok(json!({ "event": prepare_observation(&mut open_retention_db(&scope.db_path)?, &scope)? })) +} + +fn prepare_observation(conn: &mut Connection, scope: &RetentionScope) -> Result { + let saved = prepare(conn, scope)?; + let event: Event = serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?; + let profile = DesktopProfile::read( + &event, + &scope.owner_keys, + scope.relay_url.trim_end_matches('/'), + )?; + DesktopObservation::new(profile).sign(&scope.owner_keys) +} + +/// Verify bounded owner-private observations; the UI treats their timestamps as advisory. +#[tauri::command] +pub fn read_desktop_observations( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, + events: Vec, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + if events.len() > 100 { + return Err("too many Desktop observations".into()); + } + let mut rows = Vec::with_capacity(events.len()); + for event in &events { + let observation = DesktopObservation::read(event, &scope.owner_keys, &community)?; + rows.push(json!({ "id": observation.id, "heard": event.created_at.as_secs() })); + } + Ok(json!(rows)) +} + #[cfg(test)] mod tests { use super::*; @@ -119,6 +163,16 @@ mod tests { let accepted = prepare(&mut reopened, &scope).unwrap(); assert_eq!(accepted, first); assert_eq!(reopened.total_changes(), 0, "no repeated native writes"); + let pulse = prepare_observation(&mut reopened, &scope).unwrap(); + let observation = + DesktopObservation::read(&pulse, &scope.owner_keys, &scope.relay_url).unwrap(); + assert_eq!(first["event"]["tags"][0][1], observation.id); + assert_eq!( + prepare(&mut reopened, &scope).unwrap(), + first, + "pulses never rewrite profiles" + ); + assert_eq!(reopened.total_changes(), 0); let other = RetentionScope { db_path: dir.path().join("two.db"), relay_url: scope.relay_url.clone(), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 65fe1aaa4d7..edffed5e462 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -554,6 +554,8 @@ pub fn run() { get_identity, prepare_desktop_profile, read_desktop_profiles, + prepare_desktop_observation, + read_desktop_observations, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs index 4ffa141e0d2..6f7330d978c 100644 --- a/desktop/src/features/agents/desktopList.test.mjs +++ b/desktop/src/features/agents/desktopList.test.mjs @@ -155,10 +155,11 @@ test("rendered list distinguishes current, partial, unavailable and empty withou render({ ...list, rows: [], partial: false }), /No Desktop profiles found/, ); - assert.doesNotMatch(html, /Last heard|Online|Offline/); + assert.match(html, /Last heard: Unknown/); + assert.doesNotMatch(html, /Online|Offline/); }); -test("mounted cache clears both scopes, fences late reads and retains rows on failure", async () => { +test("mounted cache clears both scopes, fences late reads and retains rows on failure", async (t) => { const { JSDOM } = await import("jsdom"); const dom = new JSDOM("
", { url: "https://desktop.test", @@ -198,8 +199,26 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa let current; const originalFetch = relayClient.fetchEvents; const originalPublish = relayClient.publishEvent; + const originalReconnect = relayClient.subscribeToReconnects; + let reconnect; + let pulses = 0; + relayClient.subscribeToReconnects = (callback) => { + reconnect = callback; + return () => { + reconnect = undefined; + }; + }; window.__TAURI_INTERNALS__ = { invoke: async (command, args) => { + if (command === "prepare_desktop_observation") + return { event: { ...first, kind: 30181 } }; + if (command === "read_desktop_observations") + return [ + { + id: `${args.owner}-${args.community}`, + heard: Math.floor(Date.now() / 1000), + }, + ]; if (command === "prepare_desktop_profile") { current = { ...first, @@ -218,6 +237,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa }; relayClient.fetchEvents = async (filter) => { if (fail) throw Error("unavailable"); + if (filter.kinds[0] === 30181) return []; if (filter["#d"]) return [current]; const rows = [current]; if (hold) { @@ -228,8 +248,9 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa } return rows; }; - relayClient.publishEvent = async () => { - throw Error("unexpected rewrite"); + relayClient.publishEvent = async (event) => { + assert.equal(event.kind, 30181, "no profile heartbeat rewrite"); + pulses++; }; function Screen() { controls = useCommunities(); @@ -246,6 +267,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa await new Promise((resolve) => setTimeout(resolve, 20)); }); const text = () => document.body.textContent; + t.mock.timers.enable({ apis: ["setInterval"] }); try { await React.act(async () => root.render( @@ -262,6 +284,15 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa ); await settle(); assert.match(text(), /owner-a-wss:\/\/a.example/); + assert.match(text(), /Last heard: Recent/); + const beforeReconnect = pulses; + await React.act(async () => reconnect()); + await settle(); + assert.ok(pulses > beforeReconnect, "reconnect reports a fresh pulse"); + const beforeTimer = pulses; + await React.act(async () => t.mock.timers.tick(60_000)); + await settle(); + assert.ok(pulses > beforeTimer, "bounded periodic publisher runs"); hold = true; await React.act(async () => { void client.refetchQueries({ queryKey: ["desktop-profiles"] }); @@ -283,8 +314,11 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa fail = true; await React.act(async () => { await client.refetchQueries({ queryKey: ["desktop-profiles"] }); + await client.refetchQueries({ queryKey: ["desktop-observations"] }); }); await settle(); + assert.match(text(), /Last-heard refresh unavailable/); + assert.match(text(), /Last heard: Recent/); assert.match(text(), /unavailable/); assert.match(text(), /owner-b-wss:\/\/a.example/); await React.act(async () => controls.switchCommunity("b")); @@ -293,6 +327,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa fail = false; await React.act(async () => { await client.refetchQueries({ queryKey: ["desktop-profiles"] }); + await client.refetchQueries({ queryKey: ["desktop-observations"] }); }); await settle(); assert.match(text(), /owner-b-wss:\/\/b.example/); @@ -301,6 +336,13 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa client.clear(); relayClient.fetchEvents = originalFetch; relayClient.publishEvent = originalPublish; + relayClient.subscribeToReconnects = originalReconnect; + assert.equal( + reconnect, + undefined, + "reconnect producer unsubscribed on unmount", + ); + t.mock.timers.reset(); dom.window.close(); } }); diff --git a/desktop/src/features/agents/desktopObservations.test.mjs b/desktop/src/features/agents/desktopObservations.test.mjs new file mode 100644 index 00000000000..af9418365f9 --- /dev/null +++ b/desktop/src/features/agents/desktopObservations.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + refreshDesktopObservations, + desktopFreshness, +} from "./desktopObservations.ts"; + +const scope = { owner: "owner-a", community: "wss://a.example" }; +function fixture(boundary) { + let epoch = 0; + const calls = []; + const event = { id: "pulse", kind: 30181 }; + const rows = [ + { id: "a", heard: 100 }, + { id: "b", heard: 10 }, + ]; + const finish = (name, value) => { + if (name === boundary) epoch++; + return value; + }; + const f = { + calls, + rows, + ipc: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push(command); + if (command === "prepare_desktop_observation") + return finish(command, { event }); + assert.equal(command, "read_desktop_observations"); + return finish(command, rows); + }, + relay: { + getSessionEpoch: () => epoch, + publishEvent: async (value) => { + assert.equal(value, event); + calls.push("publish"); + finish("publishEvent"); + }, + fetchEvents: async (filter) => { + assert.deepEqual(filter, { + kinds: [30181], + authors: [scope.owner], + limit: 100, + }); + calls.push("fetch"); + return finish("fetchEvents", [event]); + }, + }, + }; + f.refresh = (active = () => true) => + refreshDesktopObservations(scope, active, f.ipc, f.relay); + return f; +} + +test("every async completion is fenced, including decrypt and late ACK", async () => { + for (const boundary of [ + "prepare_desktop_observation", + "publishEvent", + "fetchEvents", + "read_desktop_observations", + ]) { + const f = fixture(boundary); + await assert.rejects(f.refresh(), /scope changed/); + if (boundary === "prepare_desktop_observation") + assert.ok(!f.calls.includes("publish")); + } + const f = fixture(); + await assert.rejects(f.refresh(() => false)); + assert.deepEqual(f.calls, []); +}); + +test("bounded/invalid reads are not silently authoritative, clocks and staleness stay advisory", async () => { + const f = fixture(); + const good = await f.refresh(); + assert.deepEqual(good.rows, f.rows); + assert.deepEqual(f.calls, [ + "prepare_desktop_observation", + "publish", + "fetch", + "read_desktop_observations", + ]); + f.relay.publishEvent = async () => { + throw Error("offline"); + }; + assert.ok((await f.refresh()).warning); + f.relay.fetchEvents = async () => Array(100).fill({}); + assert.equal((await f.refresh()).partial, true); + const read = f.ipc; + f.ipc = async (command, args) => { + if (command === "read_desktop_observations") + throw Error("invalid signature"); + return read(command, args); + }; + await assert.rejects(f.refresh(), /invalid signature/); + for (const [heard, now, expected] of [ + [undefined, 100, "Unknown"], + [100, 280, "Recent"], + [100, 281, "Stale"], + [101, 100, "Unknown (Desktop clock ahead)"], + ]) + assert.ok(desktopFreshness(heard, now).startsWith(expected)); +}); diff --git a/desktop/src/features/agents/desktopObservations.ts b/desktop/src/features/agents/desktopObservations.ts new file mode 100644 index 00000000000..5de30fe8139 --- /dev/null +++ b/desktop/src/features/agents/desktopObservations.ts @@ -0,0 +1,82 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useQuery } from "@tanstack/react-query"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; + +export type DesktopObservation = { id: string; heard: number }; +export const DESKTOP_PULSE_MS = 60_000; + +/** A failed pulse does not hide other Desktops; failed reads retain cached observations. */ +export async function refreshDesktopObservations( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +) { + const epoch = relay.getSessionEpoch(); + const check = () => { + if (!active() || epoch !== relay.getSessionEpoch()) + throw new Error("Desktop observation scope changed"); + }; + const wait = async (work: Promise) => { + const result = await work; + check(); + return result; + }; + check(); + let warning = ""; + try { + const { event } = await wait( + ipc<{ event: RelayEvent }>("prepare_desktop_observation", scope), + ); + await wait( + relay.publishEvent( + event, + "Desktop pulse timed out", + "Desktop pulse failed", + ), + ); + } catch { + check(); + // The next bounded interval retries with a new observation, not an old heartbeat. + warning = "This Desktop could not report its last-heard time. Will retry."; + } + const events = await wait( + relay.fetchEvents({ kinds: [30181], authors: [scope.owner], limit: 100 }), + ); + const rows = await wait( + ipc("read_desktop_observations", { + ...scope, + events, + }), + ); + return { rows, warning, partial: events.length === 100 }; +} + +export function useDesktopObservations(scope: DesktopScope | null) { + return useQuery({ + queryKey: ["desktop-observations", scope?.owner, scope?.community], + enabled: !!scope, + queryFn: ({ signal }) => { + if (!scope) throw new Error("Desktop scope unavailable"); + return refreshDesktopObservations(scope, () => !signal.aborted); + }, + gcTime: 0, + staleTime: DESKTOP_PULSE_MS / 2, + retry: false, + refetchOnWindowFocus: false, + }); +} + +/** Signed sender time is advisory, including clock skew; it never establishes agent death. */ +export function desktopFreshness(heard: number | undefined, now: number) { + if (heard === undefined) return "Unknown"; + const state = + heard > now + ? "Unknown (Desktop clock ahead)" + : now - heard <= 180 + ? "Recent" + : "Stale"; + return `${state} · ${new Date(heard * 1000).toLocaleString()}`; +} diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx index 1d986d7bfd2..68eeacc1bcb 100644 --- a/desktop/src/features/agents/ui/KnownDesktops.tsx +++ b/desktop/src/features/agents/ui/KnownDesktops.tsx @@ -1,25 +1,40 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; import { relayClient } from "@/shared/api/relayClient"; import { Button } from "@/shared/ui/button"; import { refreshDesktopList, type DesktopList } from "../desktopList"; +import { + DESKTOP_PULSE_MS, + desktopFreshness, + useDesktopObservations, + type DesktopObservation, +} from "../desktopObservations"; type View = { list: DesktopList | null; error: boolean; loading: boolean; refresh: () => void; + observations?: DesktopObservation[]; + observationWarning?: string; + now?: number; }; -function useDesktopList() { +function useDesktopScope() { const owner = useIdentityQuery().data?.pubkey; const { activeCommunity } = useCommunities(); const community = activeCommunity?.relayUrl .trim() .replace(/^http/, "ws") .replace(/\/+$/, ""); + return owner && community ? { owner, community } : null; +} + +function useDesktopList() { + const scope = useDesktopScope(); + const { owner, community } = scope ?? {}; return useQuery({ queryKey: ["desktop-profiles", owner, community], enabled: !!owner && !!community, @@ -38,31 +53,62 @@ function useDesktopList() { /** Startup and Agents share the existing owner/community query cache. */ export function DesktopListStartup() { const { refetch } = useDesktopList(); - useEffect( - () => - relayClient.subscribeToReconnects(() => { - void refetch(); - }), - [refetch], - ); + const { refetch: pulse } = useDesktopObservations(useDesktopScope()); + useEffect(() => { + const timer = setInterval(() => { + void pulse(); + }, DESKTOP_PULSE_MS); + const unsubscribe = relayClient.subscribeToReconnects(() => { + void refetch(); + void pulse(); + }); + return () => { + clearInterval(timer); + unsubscribe(); + }; + }, [refetch, pulse]); return null; } export function KnownDesktops() { const query = useDesktopList(); + const observations = useDesktopObservations(useDesktopScope()); + const [now, setNow] = useState(() => Date.now() / 1000); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now() / 1000), 30_000); + return () => clearInterval(timer); + }, []); return ( { void query.refetch(); + void observations.refetch(); }} /> ); } -export function DesktopListView({ list, loading, error, refresh }: View) { +export function DesktopListView({ + list, + loading, + error, + refresh, + observations, + observationWarning, + now = Date.now() / 1000, +}: View) { return (

Private to you. Saved profiles do not indicate whether a Desktop is - online or ready to run agents. + online or ready to run agents. Last heard is a Desktop observation, not + proof that its agents are running or stopped.

{loading &&

Loading Desktop profiles…

} {error && ( @@ -89,6 +136,7 @@ export function DesktopListView({ list, loading, error, refresh }: View) { Desktop profiles unavailable. Previously loaded profiles are retained.

)} + {observationWarning &&

{observationWarning}

} {list?.warning &&

{list.warning}

} {list?.partial && (

Partial list: showing up to 100 profiles.

@@ -105,6 +153,13 @@ export function DesktopListView({ list, loading, error, refresh }: View) { {new Date(row.updated * 1000).toLocaleString()}
+
+ Last heard:{" "} + {desktopFreshness( + observations?.find((item) => item.id === row.id)?.heard, + now, + )} +
))} diff --git a/migrations/0046_desktop_observation_fts.sql b/migrations/0046_desktop_observation_fts.sql new file mode 100644 index 00000000000..ba4776fd040 --- /dev/null +++ b/migrations/0046_desktop_observation_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop observations must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +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 = 30181 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 59147e4204e..16613480710 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30180, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From 23e8f850c0c68d4de10f35426f2f41c507827b32 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 11:27:37 -0400 Subject: [PATCH 05/51] fix(desktop): fence delayed pulses and order host observations Signed-off-by: Logan Johnson --- .../agents/desktopObservations.test.mjs | 22 +++++++++ .../features/agents/desktopObservations.ts | 9 +++- .../api/relayClientPublishRejection.test.mjs | 49 +++++++++++++++++++ desktop/src/shared/api/relayClientSession.ts | 2 + desktop/src/shared/api/relayEventPublisher.ts | 14 +++++- 5 files changed, 94 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/desktopObservations.test.mjs b/desktop/src/features/agents/desktopObservations.test.mjs index af9418365f9..b3166ba25d8 100644 --- a/desktop/src/features/agents/desktopObservations.test.mjs +++ b/desktop/src/features/agents/desktopObservations.test.mjs @@ -101,3 +101,25 @@ test("bounded/invalid reads are not silently authoritative, clocks and staleness ]) assert.ok(desktopFreshness(heard, now).startsWith(expected)); }); + +test("history/live replacement races select newest signed time and lower ID, not arrival", async () => { + const f = fixture(); + const events = [ + { id: "old", created_at: 10 }, + { id: "b", created_at: 20 }, + { id: "a", created_at: 20 }, + ]; + const ipc = f.ipc; + f.ipc = async (command, args) => { + if (command === "read_desktop_observations") + assert.deepEqual( + args.events.map((e) => e.id), + ["a", "b", "old"], + ); + return ipc(command, args); + }; + for (const batch of [events, [...events].reverse()]) { + f.relay.fetchEvents = async () => batch; + await f.refresh(); + } +}); diff --git a/desktop/src/features/agents/desktopObservations.ts b/desktop/src/features/agents/desktopObservations.ts index 5de30fe8139..d548fa42433 100644 --- a/desktop/src/features/agents/desktopObservations.ts +++ b/desktop/src/features/agents/desktopObservations.ts @@ -35,6 +35,7 @@ export async function refreshDesktopObservations( event, "Desktop pulse timed out", "Desktop pulse failed", + check, ), ); } catch { @@ -48,7 +49,13 @@ export async function refreshDesktopObservations( const rows = await wait( ipc("read_desktop_observations", { ...scope, - events, + // History is chronological and may include a live replacement before EOSE. + // First matching host wins in the view: newest signed time, then lower ID. + events: [...events].sort( + (a, b) => + b.created_at - a.created_at || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ), }), ); return { rows, warning, partial: events.length === 100 }; diff --git a/desktop/src/shared/api/relayClientPublishRejection.test.mjs b/desktop/src/shared/api/relayClientPublishRejection.test.mjs index 7875b8679ab..f323623ef6f 100644 --- a/desktop/src/shared/api/relayClientPublishRejection.test.mjs +++ b/desktop/src/shared/api/relayClientPublishRejection.test.mjs @@ -293,3 +293,52 @@ test("a community switch after send failure cannot retry through its replacement ); assert.equal(eventFrames().length, 0); }); + +test("Desktop pulse cancellation fences rate-limit and reconnect continuations", async () => { + const { refreshDesktopObservations } = await import( + "../../features/agents/desktopObservations.ts" + ); + for (const boundary of ["rate-limit", "reconnect"]) { + reset(); + const client = connectedClient(); + const scope = { owner: "owner", community: "wss://a.example" }; + let active = true; + const reconnect = deferred(); + let reconnecting = false; + client.ensureConnected = async () => { + reconnecting = true; + await reconnect.promise; + client.wsId = 8; + return client.connectionGeneration; + }; + if (boundary === "rate-limit") activateRateLimit(4); + else + sendTransport = async () => { + throw Error("socket failed"); + }; + const result = refreshDesktopObservations( + scope, + () => active, + async (command) => { + assert.equal(command, "prepare_desktop_observation"); + return { event: { id: "pulse", kind: 30181 } }; + }, + client, + ); + const rejected = assert.rejects(result, /scope changed/); + if (boundary === "reconnect") await flushUntil(() => reconnecting); + else await Promise.resolve(); + active = false; + const generation = client.connectionGeneration; + resetRateLimitGate(); + reconnect.resolve(); + await rejected; + assert.equal(eventFrames().length, 0, boundary); + assert.equal(client.pendingEvents.size, 0); + assert.equal( + client.connectionGeneration, + generation, + "cancellation is not socket failure", + ); + } +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 93384a40741..3839d01c7e5 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -725,6 +725,7 @@ export class RelayClient { event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + assertActive?: () => void, ) { return publishSessionEvent( { @@ -742,6 +743,7 @@ export class RelayClient { event, timeoutMessage, sendErrorMessage, + assertActive, ); } diff --git a/desktop/src/shared/api/relayEventPublisher.ts b/desktop/src/shared/api/relayEventPublisher.ts index ff719926700..6f6b6b7c824 100644 --- a/desktop/src/shared/api/relayEventPublisher.ts +++ b/desktop/src/shared/api/relayEventPublisher.ts @@ -19,9 +19,11 @@ export async function publishSessionEvent( event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + assertActive: () => void = () => {}, ): Promise { const publishOwnership = session.ownership(); await waitForRateLimit(); + assertActive(); if (publishOwnership !== session.ownership()) { throw new Error("Relay disconnected for community switch."); } @@ -48,6 +50,14 @@ export async function publishSessionEvent( return; } + try { + assertActive(); + } catch (error) { + window.clearTimeout(timeout); + session.pendingEvents.delete(event.id); + reject(error); + return; + } // Expected socket recovery must not reject the operation being retried. session.pendingEvents.delete(event.id); const sendError = session.recoverSocketFailure(error, sendErrorMessage); @@ -55,7 +65,9 @@ export async function publishSessionEvent( let retryGeneration: number | null = null; try { - retryGeneration = await session.reconnect(); + const reconnected = await session.reconnect(); + assertActive(); + retryGeneration = reconnected; if ( publishOwnership !== session.ownership() || session.generation() !== retryGeneration || From 0fdf436105b377ae94d82f9436ed9cdff7ac0675 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 11:52:30 -0400 Subject: [PATCH 06/51] feat(desktop): report private per-Desktop runtime capabilities Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_capabilities.rs | 203 ++++++++++++++++++ crates/buzz-core/src/kind.rs | 4 + crates/buzz-core/src/lib.rs | 1 + crates/buzz-db/src/runtime/migration.rs | 19 +- .../src/api/desktop_profile_postgres_tests.rs | 12 +- crates/buzz-relay/src/handlers/event.rs | 5 + crates/buzz-relay/src/handlers/ingest.rs | 17 +- .../src/commands/desktop_capabilities.rs | 159 ++++++++++++++ .../src/commands/desktop_profiles.rs | 4 +- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 2 + .../agents/desktopCapabilities.test.mjs | 132 ++++++++++++ .../features/agents/desktopCapabilities.ts | 85 ++++++++ .../agents/ui/DesktopCapabilityDetails.tsx | 46 ++++ .../src/features/agents/ui/KnownDesktops.tsx | 30 ++- migrations/0047_desktop_capabilities_fts.sql | 26 +++ schema/schema.sql | 2 +- 17 files changed, 737 insertions(+), 12 deletions(-) create mode 100644 crates/buzz-core/src/desktop_capabilities.rs create mode 100644 desktop/src-tauri/src/commands/desktop_capabilities.rs create mode 100644 desktop/src/features/agents/desktopCapabilities.test.mjs create mode 100644 desktop/src/features/agents/desktopCapabilities.ts create mode 100644 desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx create mode 100644 migrations/0047_desktop_capabilities_fts.sql diff --git a/crates/buzz-core/src/desktop_capabilities.rs b/crates/buzz-core/src/desktop_capabilities.rs new file mode 100644 index 00000000000..c621cca9776 --- /dev/null +++ b/crates/buzz-core/src/desktop_capabilities.rs @@ -0,0 +1,203 @@ +//! Bounded, owner-private runtime facts, not signing access or agent readiness. +use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_CAPABILITIES}; +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +/// Allowlisted projection of a built-in runtime; never catalog paths or auth data. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeFact { + /// Built-in catalog identifier. + pub id: String, + /// Discovery's installation/adapter availability, not authentication. + pub availability: String, + /// Whether a separate vendor CLI is required. + pub requires_external_cli: bool, + /// Spawn policy cap; None means no configured cap, not infinite capacity. + pub max_parallelism: Option, +} + +/// Facts at the signed event time, changed only when the projection changes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DesktopCapabilities { + /// Format version. + pub v: u8, + /// Encrypted canonical community. + pub community: String, + /// Local Desktop coordinate. + pub id: String, + /// Sorted, unique built-in runtime facts. + pub runtimes: Vec, +} + +/// Validate the bounded public envelope without decrypting it. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_CAPABILITIES) +} + +impl DesktopCapabilities { + /// Project onto the persisted Desktop coordinate, not a caller-selected host. + pub fn new(profile: DesktopProfile, mut runtimes: Vec) -> Self { + runtimes.sort_by(|a, b| a.id.cmp(&b.id)); + Self { + v: 1, + community: profile.community, + id: profile.id, + runtimes, + } + } + + fn validate(&self) -> Result<(), String> { + DesktopProfile::new(self.community.clone(), self.id.clone())?; + if self.v != 1 + || self.runtimes.len() > 8 + || self.runtimes.windows(2).any(|r| r[0].id >= r[1].id) + || self.runtimes.iter().any(|r| { + r.id.is_empty() + || r.id.len() > 32 + || !r.id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') + || !matches!( + r.availability.as_str(), + "available" + | "adapter_missing" + | "adapter_outdated" + | "cli_missing" + | "not_installed" + ) + || r.max_parallelism == Some(0) + }) + { + return Err("invalid Desktop runtime facts".into()); + } + Ok(()) + } + + /// Encrypt/sign once, then persist these exact bytes for retries. + pub fn sign(&self, keys: &Keys) -> Result { + self.validate()?; + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(self).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content) + .tag(Tag::identifier(&self.id)) + .sign_with_keys(keys) + .map_err(|e| e.to_string())?; + validate_envelope(&event)?; + Ok(event) + } + + /// Bounded history/live merge: newest signed time, lower event ID on ties. + pub fn read_latest( + mut events: Vec, + keys: &Keys, + community: &str, + ) -> Result, String> { + if events.len() > 100 { + return Err("too many Desktop reports".into()); + } + events.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id))); + let mut seen = std::collections::HashSet::new(); + let mut rows = Vec::new(); + for event in events { + let report = Self::read(&event, keys, community)?; + if seen.insert(report.id.clone()) { + rows.push((report, event.created_at.as_secs())); + } + } + Ok(rows) + } + + /// Authenticate, decrypt and scope-check before exposing any fact. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop report signature")?; + if event.pubkey != keys.public_key() { + return Err("foreign Desktop report".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop report decryption failed")?; + let report: Self = + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop report")?; + report.validate()?; + if report.community != community || Some(report.id.as_str()) != event.tags.identifier() { + return Err("Desktop report scope mismatch".into()); + } + Ok(report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn private_scoped_bounded_facts() { + let keys = Keys::generate(); + let mut report = DesktopCapabilities::new( + DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(), + vec![], + ); + let event = report.sign(&keys).unwrap(); + assert_eq!( + DesktopCapabilities::read(&event, &keys, &report.community).unwrap(), + report + ); + assert!(DesktopCapabilities::read(&event, &keys, "wss://two.example").is_err()); + assert!(DesktopCapabilities::read(&event, &Keys::generate(), &report.community).is_err()); + let mut payload = serde_json::to_value(&report).unwrap(); + payload["auth"] = serde_json::json!("must not appear"); + let ciphertext = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + payload.to_string(), + nip44::Version::V2, + ) + .unwrap(); + let invalid = EventBuilder::new(event.kind, ciphertext) + .tags(event.tags.clone()) + .sign_with_keys(&keys) + .unwrap(); + assert!(DesktopCapabilities::read(&invalid, &keys, &report.community).is_err()); + let mut tampered = event; + tampered.created_at = nostr::Timestamp::from(1); + assert!(DesktopCapabilities::read(&tampered, &keys, &report.community).is_err()); + report.runtimes.push(RuntimeFact { + id: "/private/path".into(), + availability: "available".into(), + requires_external_cli: false, + max_parallelism: None, + }); + assert!(report.sign(&keys).is_err()); + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_CAPABILITIES)); + report.runtimes[0].id = "goose".into(); + let old = report.sign(&keys).unwrap(); + report.runtimes[0].availability = "cli_missing".into(); + let new = report.sign(&keys).unwrap(); + let signed = |event: &Event, time| { + EventBuilder::new(event.kind, &event.content) + .tags(event.tags.clone()) + .custom_created_at(nostr::Timestamp::from(time)) + .sign_with_keys(&keys) + .unwrap() + }; + let a = signed(&old, 20); + let b = signed(&new, 20); + let winner = if a.id < b.id { &a } else { &b }; + let expected = DesktopCapabilities::read(winner, &keys, &report.community).unwrap(); + for events in [vec![signed(&old, 10), a.clone(), b.clone()], vec![b, a]] { + assert_eq!( + DesktopCapabilities::read_latest(events, &keys, &report.community).unwrap(), + vec![(expected.clone(), 20)] + ); + } + assert!( + DesktopCapabilities::read_latest(vec![old; 101], &keys, &report.community).is_err() + ); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 1e5bfd43d6e..4d309c3b609 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -122,6 +122,8 @@ pub const KIND_DESKTOP_PROFILE: u32 = 30180; /// Owner-private, per-Desktop last-heard observation; not online or readiness. pub const KIND_DESKTOP_OBSERVATION: u32 = 30181; +/// Owner-private built-in runtime facts per Desktop, not agent readiness. +pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182; /// Kinds whose stored events are readable only by their author. /// @@ -138,6 +140,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_PRIVATE_MANAGED_AGENT, KIND_DESKTOP_PROFILE, KIND_DESKTOP_OBSERVATION, + KIND_DESKTOP_CAPABILITIES, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -669,6 +672,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PRIVATE_MANAGED_AGENT, KIND_DESKTOP_PROFILE, KIND_DESKTOP_OBSERVATION, + KIND_DESKTOP_CAPABILITIES, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 2105e4a15a7..ee4bb5df789 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +pub mod desktop_capabilities; pub mod desktop_observation; /// Owner-private Desktop display profiles. pub mod desktop_profile; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 5a0d2a64150..7da5d0d0c68 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 46); + assert_eq!(migrations.len(), 47); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -911,7 +911,7 @@ mod postgres_tests { 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, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200)" + "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200)" )); // Public push-gateway authority is intentionally deployment-global and @@ -2393,6 +2393,7 @@ mod postgres_tests { (3_u8, 30_179_i32), (4_u8, 30_180_i32), (5_u8, 30_181_i32), + (6_u8, 30_182_i32), ] { sqlx::query( "INSERT INTO events \ @@ -2427,6 +2428,7 @@ mod postgres_tests { (30_179, true), (30_180, true), (30_181, true), + (30_182, true), (30_350, true) ] ); @@ -2451,6 +2453,7 @@ mod postgres_tests { (30_179, Some(true)), (30_180, Some(true)), (30_181, Some(true)), + (30_182, Some(true)), (30_350, None) ] ); @@ -2479,6 +2482,17 @@ mod postgres_tests { "0046 must change brownfield observation FTS" ); + run_migrations_through(&pool, 46).await.unwrap(); + let capability_indexed: bool = + sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30182") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + capability_indexed, + "0047 must change brownfield capability FTS" + ); + run_migrations(&pool) .await .expect("apply remaining migrations to populated database"); @@ -2496,6 +2510,7 @@ mod postgres_tests { (30_179, None), (30_180, None), (30_181, None), + (30_182, None), (30_350, None) ] ); diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs index 5d863666832..80f4619c583 100644 --- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -2,7 +2,7 @@ use super::postgres_tests::bridge_handler_test_state; use super::*; use axum::{body::Body, http::Request}; -use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; +use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; use tower::ServiceExt; @@ -76,6 +76,12 @@ async fn desktop_observation_authenticated_owner_query_and_private_storage() { assert_private_desktop(KIND_DESKTOP_OBSERVATION).await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_capabilities_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_CAPABILITIES).await; +} + async fn assert_private_desktop(kind: u32) { let mut state = bridge_handler_test_state() .await @@ -99,6 +105,10 @@ async fn assert_private_desktop(kind: u32) { let id = profile.id.clone(); let event = if kind == KIND_DESKTOP_PROFILE { profile.sign(&owner).unwrap() + } else if kind == KIND_DESKTOP_CAPABILITIES { + buzz_core::desktop_capabilities::DesktopCapabilities::new(profile, vec![]) + .sign(&owner) + .unwrap() } else { buzz_core::desktop_observation::DesktopObservation::new(profile) .sign(&owner) diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index b2f86ec357c..832b5678c9d 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -2220,6 +2220,11 @@ mod tests { assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await; } + #[tokio::test] + async fn desktop_capabilities_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_CAPABILITIES).await; + } + async fn assert_author_only_fanout(kind: u32) { let state = test_state().await; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index f60a76fd4a8..9d4e9ad5dc4 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -36,7 +36,7 @@ use buzz_core::kind::{ RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; -use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; +use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; @@ -437,7 +437,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 | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION => Ok(Scope::UsersWrite), + KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES => 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 @@ -660,6 +660,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_PRIVATE_MANAGED_AGENT | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION + | KIND_DESKTOP_CAPABILITIES | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -2170,14 +2171,14 @@ pub async fn ingest_event( result } -// Profiles are durable display records, not freshness signals. A Desktop may +// Profiles and capability facts are durable records, not freshness signals. A Desktop may // first publish its immutable signed record long after an offline startup. // Only their past-age bound is waived; future drift and all other admission // checks still apply. Observation/presence kinds must retain their own window. fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool { const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900; event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS) - && (kind == KIND_DESKTOP_PROFILE + && (matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES) || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) } @@ -2793,6 +2794,11 @@ async fn ingest_event_inner( } } + if kind_u32 == KIND_DESKTOP_CAPABILITIES { + buzz_core::desktop_capabilities::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_DESKTOP_OBSERVATION { buzz_core::desktop_observation::validate_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3338,6 +3344,7 @@ mod postgres_tests { // Include the next observation kind explicitly: freshness is not profile age. for kind in [ KIND_DESKTOP_PROFILE, + KIND_DESKTOP_CAPABILITIES, 30181, KIND_PROFILE, KIND_EVENT_REMINDER, @@ -3355,7 +3362,7 @@ mod postgres_tests { ] { assert_eq!( timestamp_within_ingest_window(kind, timestamp, now), - if kind == KIND_DESKTOP_PROFILE { + if matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES) { profile } else { ordinary diff --git a/desktop/src-tauri/src/commands/desktop_capabilities.rs b/desktop/src-tauri/src/commands/desktop_capabilities.rs new file mode 100644 index 00000000000..bdab02274eb --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_capabilities.rs @@ -0,0 +1,159 @@ +//! Private Desktop reports reuse the local catalog authority and retention scope. +use super::desktop_profiles::{prepare, scope}; +use crate::{ + app_state::AppState, + managed_agents::{ + retention::{open_retention_db, RetentionScope}, + AcpRuntimeCatalogEntry, HarnessSource, + }, +}; +use buzz_core_pkg::{ + desktop_capabilities::{DesktopCapabilities, RuntimeFact}, + desktop_profile::DesktopProfile, +}; +use nostr::{Event, JsonUtil}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior}; +use serde_json::{json, Value}; +use tauri::{AppHandle, State}; + +fn project(catalog: Vec) -> Result, String> { + catalog + .into_iter() + .filter(|r| r.source == HarnessSource::Builtin) + .map(|r| { + Ok(RuntimeFact { + id: r.id, + availability: serde_json::from_value( + serde_json::to_value(r.availability).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?, + requires_external_cli: r.requires_external_cli, + max_parallelism: r.max_parallelism, + }) + }) + .collect() +} + +/// Cached discovery only; Settings → Agents remains the local setup/check-again UI. +#[tauri::command] +pub async fn prepare_desktop_capabilities( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, +) -> Result { + // Serialize discovery + persistence so an older native completion cannot + // overwrite a newer projection when observers cancel/restart. + static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let _guard = SERIAL.lock().await; + scope(&app, &state, &owner, &community)?; + let facts = + project(super::agent_discovery::discover_acp_providers(app.clone(), Some(false)).await?)?; + let scope = scope(&app, &state, &owner, &community)?; + Ok(json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts)? })) +} + +fn prepare_report( + conn: &mut Connection, + scope: &RetentionScope, + facts: Vec, +) -> Result { + let saved = prepare(conn, scope)?; + let profile: Event = + serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?; + let community = scope.relay_url.trim_end_matches('/'); + let report = DesktopCapabilities::new( + DesktopProfile::read(&profile, &scope.owner_keys, community)?, + facts, + ); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + tx.execute_batch("CREATE TABLE IF NOT EXISTS desktop_capabilities (slot INTEGER PRIMARY KEY CHECK(slot = 1), raw TEXT NOT NULL);").map_err(|e| e.to_string())?; + let raw: Option = tx + .query_row( + "SELECT raw FROM desktop_capabilities WHERE slot = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + let previous = raw + .map(|raw| Event::from_json(raw).map_err(|e| e.to_string())) + .transpose()?; + let unchanged = previous + .as_ref() + .map(|e| { + DesktopCapabilities::read(e, &scope.owner_keys, community).map(|old| old == report) + }) + .transpose()? + .unwrap_or(false); + let event = match previous { + Some(event) if unchanged => event, + _ => { + let event = report.sign(&scope.owner_keys)?; + tx.execute( + "INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)", + [event.as_json()], + ) + .map_err(|e| e.to_string())?; + event + } + }; + tx.commit().map_err(|e| e.to_string())?; + Ok(event) +} + +/// Read only verified owner/community reports, newest signed time then lower ID. +#[tauri::command] +pub fn read_desktop_capabilities( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, + events: Vec, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + let rows: Vec<_> = DesktopCapabilities::read_latest(events, &scope.owner_keys, &community)?.into_iter() + .map(|(report, reported)| json!({ "id": report.id, "reported": reported, "runtimes": report.runtimes })).collect(); + Ok(json!(rows)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unchanged_facts_reopen_exact_bytes_changed_facts_replace_atomically() { + let dir = tempfile::tempdir().unwrap(); + let scope = RetentionScope { + db_path: dir.path().join("report.db"), + relay_url: "wss://one.example".into(), + owner_keys: nostr::Keys::generate(), + }; + let first = prepare_report( + &mut open_retention_db(&scope.db_path).unwrap(), + &scope, + vec![], + ) + .unwrap(); + let mut reopened = open_retention_db(&scope.db_path).unwrap(); + assert_eq!( + prepare_report(&mut reopened, &scope, vec![]).unwrap(), + first + ); + assert_eq!(reopened.total_changes(), 0); + let facts = vec![RuntimeFact { + id: "goose".into(), + availability: "available".into(), + requires_external_cli: true, + max_parallelism: None, + }]; + let changed = prepare_report(&mut reopened, &scope, facts).unwrap(); + assert_ne!(changed.id, first.id); + assert_eq!(changed.tags, first.tags); + reopened + .execute("UPDATE desktop_capabilities SET raw = 'corrupt'", []) + .unwrap(); + assert!(prepare_report(&mut reopened, &scope, vec![]).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs index d3438d71c51..caec1f9af88 100644 --- a/desktop/src-tauri/src/commands/desktop_profiles.rs +++ b/desktop/src-tauri/src/commands/desktop_profiles.rs @@ -8,7 +8,7 @@ use tauri::{AppHandle, State}; use crate::app_state::AppState; use crate::managed_agents::retention::{active_retention_scope, open_retention_db, RetentionScope}; -fn scope( +pub(super) fn scope( app: &AppHandle, state: &AppState, owner: &str, @@ -23,7 +23,7 @@ fn scope( Ok(scope) } -fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result { +pub(super) fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result { // SQLite serializes concurrent startup/open requests across processes. The ID // and exact ciphertext/signature commit together, before any network write. let tx = conn diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 3e3c38a3e8c..0cb37bf7e02 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod channel_templates; mod channel_window; mod channels; mod clipboard; +mod desktop_capabilities; mod desktop_profiles; mod dms; mod engrams; @@ -93,6 +94,7 @@ pub use channel_templates::*; pub use channel_window::*; pub use channels::*; pub use clipboard::*; +pub use desktop_capabilities::*; pub use desktop_profiles::*; pub use dms::*; pub use engrams::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index edffed5e462..f794243354d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -556,6 +556,8 @@ pub fn run() { read_desktop_profiles, prepare_desktop_observation, read_desktop_observations, + prepare_desktop_capabilities, + read_desktop_capabilities, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src/features/agents/desktopCapabilities.test.mjs b/desktop/src/features/agents/desktopCapabilities.test.mjs new file mode 100644 index 00000000000..9def2ec43d8 --- /dev/null +++ b/desktop/src/features/agents/desktopCapabilities.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { refreshDesktopCapabilities } from "./desktopCapabilities.ts"; +import { DesktopListView } from "./ui/KnownDesktops.tsx"; + +const scope = { owner: "owner-a", community: "wss://one.example" }; +const event = { id: "signed", created_at: 100, kind: 30182 }; +const row = { + id: "desktop-a", + reported: 100, + runtimes: [ + { + id: "goose", + availability: "cli_missing", + requires_external_cli: true, + max_parallelism: null, + }, + ], +}; +function fixture(boundary) { + let epoch = 0; + const calls = []; + const finish = (name, result) => { + if (name === boundary) epoch++; + return result; + }; + const f = { + calls, + head: [], + ipc: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push(command); + if (command === "prepare_desktop_capabilities") + return finish("prepare", { event }); + assert.equal(command, "read_desktop_capabilities"); + return finish( + "read", + args.events.map(() => row), + ); + }, + relay: { + getSessionEpoch: () => epoch, + fetchEvents: async (filter) => { + assert.deepEqual(filter.authors, [scope.owner]); + assert.deepEqual(filter.kinds, [30182]); + assert.ok(filter.limit <= 100); + return finish("fetch", filter["#d"] ? f.head : [event]); + }, + publishEvent: async (value, _timeout, _failure, check) => { + finish("transport"); + check(); // Delayed transport must invoke the production cancellation guard. + calls.push(value); + f.head = [value]; + finish("ack"); + }, + }, + }; + f.refresh = (active = () => true) => + refreshDesktopCapabilities(scope, active, f.ipc, f.relay); + return f; +} + +test("unchanged accepted report does not republish; failed publish retries exact bytes", async () => { + const f = fixture(); + assert.deepEqual((await f.refresh()).rows, [row]); + await f.refresh(); + assert.equal(f.calls.filter((c) => c === event).length, 1); + f.head = []; + f.relay.publishEvent = async (value) => { + assert.equal(value, event); + throw Error("offline"); + }; + for (let i = 0; i < 2; i++) assert.ok((await f.refresh()).warning); + f.relay.fetchEvents = async () => { + throw Error("unavailable"); + }; + await assert.rejects(f.refresh(), /unavailable/); +}); + +test("all async boundaries fence cancellation, account/community switches and late ACK", async () => { + for (const boundary of ["prepare", "read", "fetch", "transport", "ack"]) { + const f = fixture(boundary); + await assert.rejects(f.refresh(), /scope changed/); + if (boundary !== "ack") assert.ok(!f.calls.includes(event)); + } + const f = fixture(); + await assert.rejects(f.refresh(() => false)); + assert.deepEqual(f.calls, []); + f.relay.fetchEvents = async () => Array(100).fill(event); + assert.equal((await f.refresh()).partial, true); + f.ipc = async () => { + throw Error("invalid signature"); + }; + await assert.rejects(f.refresh(), /invalid signature/); +}); + +test("mounted Desktop rows show exact remote facts and unknowns, not readiness", () => { + const html = renderToStaticMarkup( + React.createElement(DesktopListView, { + list: { + rows: ["desktop-a", "desktop-b"].map((id) => ({ + id, + name: id, + updated: 1, + })), + local: "desktop-b", + }, + capabilities: [row], + now: 99, + refresh() {}, + loading: false, + error: false, + }), + ); + assert.equal( + (html.match(/Capability details<\/summary>/g) ?? []).length, + 2, + ); + for (const text of [ + "goose", + "cli missing", + "not configured", + "Desktop clock ahead", + "No capability report received", + "not agent readiness", + "Settings", + ]) + assert.ok(html.includes(text), text); +}); diff --git a/desktop/src/features/agents/desktopCapabilities.ts b/desktop/src/features/agents/desktopCapabilities.ts new file mode 100644 index 00000000000..566ef2b8117 --- /dev/null +++ b/desktop/src/features/agents/desktopCapabilities.ts @@ -0,0 +1,85 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useQuery } from "@tanstack/react-query"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; + +export type DesktopCapabilities = { + id: string; + reported: number; + runtimes: { + id: string; + availability: string; + requires_external_cli: boolean; + max_parallelism: number | null; + }[]; +}; + +/** Exact signed reports are persisted natively; only changed facts create new bytes. */ +export async function refreshDesktopCapabilities( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +) { + const epoch = relay.getSessionEpoch(); + const check = () => { + if (!active() || epoch !== relay.getSessionEpoch()) + throw new Error("Desktop capability scope changed"); + }; + const wait = async (work: Promise) => { + const result = await work; + check(); + return result; + }; + const read = (events: RelayEvent[]) => + wait( + ipc("read_desktop_capabilities", { + ...scope, + events, + }), + ); + const filter = { kinds: [30182], authors: [scope.owner] }; + check(); + let warning = ""; + try { + const { event } = await wait( + ipc<{ event: RelayEvent }>("prepare_desktop_capabilities", scope), + ); + const [local] = await read([event]); + const head = await wait( + relay.fetchEvents({ ...filter, "#d": [local.id], limit: 1 }), + ); + await read(head); + if (!head.some((e) => e.id === event.id)) + await wait( + relay.publishEvent( + event, + "Desktop report timed out", + "Desktop report failed", + check, + ), + ); + } catch { + check(); + warning = + "This Desktop could not synchronize capability facts. Will retry."; + } + const events = await wait(relay.fetchEvents({ ...filter, limit: 100 })); + return { rows: await read(events), partial: events.length === 100, warning }; +} + +export function useDesktopCapabilities(scope: DesktopScope | null) { + return useQuery({ + queryKey: ["desktop-capabilities", scope?.owner, scope?.community], + enabled: !!scope, + queryFn: ({ signal }) => { + if (!scope) throw new Error("Desktop scope unavailable"); + return refreshDesktopCapabilities(scope, () => !signal.aborted); + }, + gcTime: 0, + staleTime: 30_000, + retry: false, + refetchOnWindowFocus: false, + }); +} diff --git a/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx b/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx new file mode 100644 index 00000000000..aa18aed7a3d --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx @@ -0,0 +1,46 @@ +import type { DesktopCapabilities } from "../desktopCapabilities"; + +/** Read-only remote projection; local setup remains in Settings → Agents. */ +export function DesktopCapabilityDetails({ + report, + now, +}: { + report?: DesktopCapabilities; + now: number; +}) { + return ( +
+ Capability details + {!report ? ( +

No capability report received.

+ ) : ( + <> +

+ Facts reported {new Date(report.reported * 1000).toLocaleString()} + {report.reported > now && + " (Desktop clock ahead; report time uncertain)"} + . Unchanged facts keep their original report time. +

+
    + {report.runtimes.map((runtime) => ( +
  • + {runtime.id}: {runtime.availability.replaceAll("_", " ")} · + external CLI{" "} + {runtime.requires_external_cli ? "required" : "not required"} · + parallelism cap {runtime.max_parallelism ?? "not configured"}. +
  • + ))} +
+ {!report.runtimes.length && ( +

No built-in runtime facts reported.

+ )} + + )} +

+ Cached installation facts only, not agent readiness or access to an + agent’s signing key. Stable agent keys must be provisioned separately by + you. For local setup and Check again, use Settings → Agents. +

+
+ ); +} diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx index 68eeacc1bcb..16d872c2d8b 100644 --- a/desktop/src/features/agents/ui/KnownDesktops.tsx +++ b/desktop/src/features/agents/ui/KnownDesktops.tsx @@ -12,7 +12,15 @@ import { type DesktopObservation, } from "../desktopObservations"; +import { + useDesktopCapabilities, + type DesktopCapabilities, +} from "../desktopCapabilities"; +import { DesktopCapabilityDetails } from "./DesktopCapabilityDetails"; + type View = { + capabilities?: DesktopCapabilities[]; + capabilityWarning?: string; list: DesktopList | null; error: boolean; loading: boolean; @@ -54,25 +62,29 @@ function useDesktopList() { export function DesktopListStartup() { const { refetch } = useDesktopList(); const { refetch: pulse } = useDesktopObservations(useDesktopScope()); + const { refetch: report } = useDesktopCapabilities(useDesktopScope()); useEffect(() => { const timer = setInterval(() => { void pulse(); + void report(); }, DESKTOP_PULSE_MS); const unsubscribe = relayClient.subscribeToReconnects(() => { void refetch(); void pulse(); + void report(); }); return () => { clearInterval(timer); unsubscribe(); }; - }, [refetch, pulse]); + }, [refetch, pulse, report]); return null; } export function KnownDesktops() { const query = useDesktopList(); const observations = useDesktopObservations(useDesktopScope()); + const capabilities = useDesktopCapabilities(useDesktopScope()); const [now, setNow] = useState(() => Date.now() / 1000); useEffect(() => { const timer = setInterval(() => setNow(Date.now() / 1000), 30_000); @@ -80,6 +92,14 @@ export function KnownDesktops() { }, []); return ( { void query.refetch(); void observations.refetch(); + void capabilities.refetch(); }} /> ); @@ -107,6 +128,8 @@ export function DesktopListView({ refresh, observations, observationWarning, + capabilities, + capabilityWarning, now = Date.now() / 1000, }: View) { return ( @@ -136,6 +159,7 @@ export function DesktopListView({ Desktop profiles unavailable. Previously loaded profiles are retained.

)} + {capabilityWarning &&

{capabilityWarning}

} {observationWarning &&

{observationWarning}

} {list?.warning &&

{list.warning}

} {list?.partial && ( @@ -160,6 +184,10 @@ export function DesktopListView({ now, )} + item.id === row.id)} + now={now} + /> ))} diff --git a/migrations/0047_desktop_capabilities_fts.sql b/migrations/0047_desktop_capabilities_fts.sql new file mode 100644 index 00000000000..425d1eee92b --- /dev/null +++ b/migrations/0047_desktop_capabilities_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop capability reports must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +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 = 30182 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 16613480710..d78b3036d97 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From 09282c7283e48422e4c7f99eeb77989346a563ca Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 12:24:55 -0400 Subject: [PATCH 07/51] fix(desktop): defer capability changes until real time advances Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_capabilities.rs | 6 ++ .../src/commands/desktop_capabilities.rs | 83 +++++++++++++++++-- .../agents/desktopCapabilities.test.mjs | 28 +++++++ .../src/features/agents/desktopList.test.mjs | 34 +++++++- 4 files changed, 141 insertions(+), 10 deletions(-) diff --git a/crates/buzz-core/src/desktop_capabilities.rs b/crates/buzz-core/src/desktop_capabilities.rs index c621cca9776..ef1c4d8f63a 100644 --- a/crates/buzz-core/src/desktop_capabilities.rs +++ b/crates/buzz-core/src/desktop_capabilities.rs @@ -75,6 +75,11 @@ impl DesktopCapabilities { /// Encrypt/sign once, then persist these exact bytes for retries. pub fn sign(&self, keys: &Keys) -> Result { + self.sign_at(keys, nostr::Timestamp::now()) + } + + /// Sign at an observed wall-clock second, never a synthesized logical time. + pub fn sign_at(&self, keys: &Keys, observed: nostr::Timestamp) -> Result { self.validate()?; let content = nip44::encrypt( keys.secret_key(), @@ -85,6 +90,7 @@ impl DesktopCapabilities { .map_err(|e| e.to_string())?; let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content) .tag(Tag::identifier(&self.id)) + .custom_created_at(observed) .sign_with_keys(keys) .map_err(|e| e.to_string())?; validate_envelope(&event)?; diff --git a/desktop/src-tauri/src/commands/desktop_capabilities.rs b/desktop/src-tauri/src/commands/desktop_capabilities.rs index bdab02274eb..f5efd5c953b 100644 --- a/desktop/src-tauri/src/commands/desktop_capabilities.rs +++ b/desktop/src-tauri/src/commands/desktop_capabilities.rs @@ -50,13 +50,16 @@ pub async fn prepare_desktop_capabilities( let facts = project(super::agent_discovery::discover_acp_providers(app.clone(), Some(false)).await?)?; let scope = scope(&app, &state, &owner, &community)?; - Ok(json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts)? })) + Ok( + json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts, nostr::Timestamp::now)? }), + ) } fn prepare_report( conn: &mut Connection, scope: &RetentionScope, facts: Vec, + clock: impl FnOnce() -> nostr::Timestamp, ) -> Result { let saved = prepare(conn, scope)?; let profile: Event = @@ -90,8 +93,16 @@ fn prepare_report( .unwrap_or(false); let event = match previous { Some(event) if unchanged => event, - _ => { - let event = report.sign(&scope.owner_keys)?; + previous => { + let now = clock(); + // Keep the prior retry record until real time advances. Signing tied + // ciphertext can lose NIP-33's lower-ID tie; never cache that loss or + // future-date a replacement. The existing pulse/reconnect/Refresh + // retries discovery, not a captured projection, without waiting here. + if previous.as_ref().is_some_and(|e| now <= e.created_at) { + return Err("Desktop capability facts deferred until the clock advances".into()); + } + let event = report.sign_at(&scope.owner_keys, now)?; tx.execute( "INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)", [event.as_json()], @@ -123,7 +134,7 @@ pub fn read_desktop_capabilities( mod tests { use super::*; #[test] - fn unchanged_facts_reopen_exact_bytes_changed_facts_replace_atomically() { + fn changed_facts_defer_until_real_clock_advances_then_win_signed_order() { let dir = tempfile::tempdir().unwrap(); let scope = RetentionScope { db_path: dir.path().join("report.db"), @@ -134,26 +145,80 @@ mod tests { &mut open_retention_db(&scope.db_path).unwrap(), &scope, vec![], + || nostr::Timestamp::from(1000), ) .unwrap(); let mut reopened = open_retention_db(&scope.db_path).unwrap(); assert_eq!( - prepare_report(&mut reopened, &scope, vec![]).unwrap(), + prepare_report(&mut reopened, &scope, vec![], || panic!( + "unchanged must not sign" + )) + .unwrap(), first ); assert_eq!(reopened.total_changes(), 0); - let facts = vec![RuntimeFact { + let mut facts = vec![RuntimeFact { id: "goose".into(), availability: "available".into(), requires_external_cli: true, max_parallelism: None, }]; - let changed = prepare_report(&mut reopened, &scope, facts).unwrap(); - assert_ne!(changed.id, first.id); + for now in [1000, 990, 999, 1000] { + let error = prepare_report(&mut reopened, &scope, facts.clone(), || { + nostr::Timestamp::from(now) + }) + .unwrap_err(); + assert!(error.contains("clock advances")); + assert_eq!(reopened.total_changes(), 0, "deferral must not persist"); + // Returning to old facts cancels the proposed change, even after a + // restart/rollback: no deferred payload or timestamp renewal survives. + assert_eq!( + prepare_report(&mut reopened, &scope, vec![], || panic!("exact retry")).unwrap(), + first + ); + reopened = open_retention_db(&scope.db_path).unwrap(); + } + // The retry observes today's facts, not the projection first deferred. + facts[0].availability = "cli_missing".into(); + let changed = prepare_report(&mut reopened, &scope, facts.clone(), || { + nostr::Timestamp::from(1001) + }) + .unwrap(); + first.verify().unwrap(); + changed.verify().unwrap(); + assert_eq!(changed.created_at.as_secs(), 1001, "no future timestamp"); + assert!(changed.created_at > first.created_at); assert_eq!(changed.tags, first.tags); + for events in [ + vec![first.clone(), changed.clone()], + vec![changed.clone(), first], + ] { + let rows = + DesktopCapabilities::read_latest(events, &scope.owner_keys, &scope.relay_url) + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0.runtimes, facts); + assert_eq!(rows[0].1, 1001); + } + let mut reopened = open_retention_db(&scope.db_path).unwrap(); + let mut invalid = facts.clone(); + invalid[0].max_parallelism = Some(0); + assert!(prepare_report(&mut reopened, &scope, invalid, || { + nostr::Timestamp::from(1002) + }) + .is_err()); + assert_eq!( + prepare_report(&mut reopened, &scope, facts, || panic!("exact retry")).unwrap(), + changed + ); + assert_eq!( + reopened.total_changes(), + 0, + "failed signing must not persist" + ); reopened .execute("UPDATE desktop_capabilities SET raw = 'corrupt'", []) .unwrap(); - assert!(prepare_report(&mut reopened, &scope, vec![]).is_err()); + assert!(prepare_report(&mut reopened, &scope, vec![], nostr::Timestamp::now).is_err()); } } diff --git a/desktop/src/features/agents/desktopCapabilities.test.mjs b/desktop/src/features/agents/desktopCapabilities.test.mjs index 9def2ec43d8..da09f6bb4a8 100644 --- a/desktop/src/features/agents/desktopCapabilities.test.mjs +++ b/desktop/src/features/agents/desktopCapabilities.test.mjs @@ -80,6 +80,34 @@ test("unchanged accepted report does not republish; failed publish retries exact await assert.rejects(f.refresh(), /unavailable/); }); +test("deferred preparation settles with prior relay facts, never publishes, and honors cancellation", async () => { + const f = fixture(); + const ipc = f.ipc; + let active = true; + let cancel = false; + f.ipc = async (command, args) => { + if (command === "prepare_desktop_capabilities") { + if (cancel) active = false; + throw Error("Desktop capability facts deferred until the clock advances"); + } + return ipc(command, args); + }; + const deferred = await f.refresh(() => active); + assert.deepEqual(deferred.rows, [row]); + assert.match(deferred.warning, /Will retry/); + assert.ok(!f.calls.includes(event)); + cancel = true; + await assert.rejects( + f.refresh(() => active), + /scope changed/, + ); + assert.ok(!f.calls.includes(event)); + // A later, active attempt prepares afresh; no held promise or queued event. + f.ipc = ipc; + assert.equal((await f.refresh()).warning, ""); + assert.equal(f.calls.filter((c) => c === event).length, 1); +}); + test("all async boundaries fence cancellation, account/community switches and late ACK", async () => { for (const boundary of ["prepare", "read", "fetch", "transport", "ack"]) { const f = fixture(boundary); diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs index 6f7330d978c..5e21adbb316 100644 --- a/desktop/src/features/agents/desktopList.test.mjs +++ b/desktop/src/features/agents/desktopList.test.mjs @@ -202,6 +202,9 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa const originalReconnect = relayClient.subscribeToReconnects; let reconnect; let pulses = 0; + let reports = 0; + let publishedReports = 0; + let deferReport = true; relayClient.subscribeToReconnects = (callback) => { reconnect = callback; return () => { @@ -210,6 +213,17 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa }; window.__TAURI_INTERNALS__ = { invoke: async (command, args) => { + if (command === "prepare_desktop_capabilities") { + reports++; + if (deferReport) throw Error("clock has not advanced"); + return { event: { ...first, kind: 30182 } }; + } + if (command === "read_desktop_capabilities") + return args.events.map(() => ({ + id: "desktop-a", + reported: 100, + runtimes: [], + })); if (command === "prepare_desktop_observation") return { event: { ...first, kind: 30181 } }; if (command === "read_desktop_observations") @@ -237,7 +251,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa }; relayClient.fetchEvents = async (filter) => { if (fail) throw Error("unavailable"); - if (filter.kinds[0] === 30181) return []; + if ([30181, 30182].includes(filter.kinds[0])) return []; if (filter["#d"]) return [current]; const rows = [current]; if (hold) { @@ -249,6 +263,10 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa return rows; }; relayClient.publishEvent = async (event) => { + if (event.kind === 30182) { + publishedReports++; + return; + } assert.equal(event.kind, 30181, "no profile heartbeat rewrite"); pulses++; }; @@ -285,14 +303,28 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa await settle(); assert.match(text(), /owner-a-wss:\/\/a.example/); assert.match(text(), /Last heard: Recent/); + assert.match(text(), /could not synchronize capability facts/); const beforeReconnect = pulses; + const reportsBeforeReconnect = reports; await React.act(async () => reconnect()); await settle(); assert.ok(pulses > beforeReconnect, "reconnect reports a fresh pulse"); + assert.ok( + reports > reportsBeforeReconnect, + "reconnect retries deferred facts", + ); const beforeTimer = pulses; + const reportsBeforeTimer = reports; await React.act(async () => t.mock.timers.tick(60_000)); await settle(); assert.ok(pulses > beforeTimer, "bounded periodic publisher runs"); + assert.ok(reports > reportsBeforeTimer, "periodic retry survives deferral"); + assert.equal(publishedReports, 0, "deferred facts are not published"); + deferReport = false; + await React.act(async () => t.mock.timers.tick(60_000)); + await settle(); + assert.equal(publishedReports, 1, "later preparation is published"); + assert.doesNotMatch(text(), /could not synchronize capability facts/); hold = true; await React.act(async () => { void client.refetchQueries({ queryKey: ["desktop-profiles"] }); From d67471c60e4abd819fa3e7a37e9b905f00ef84b9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 15:58:40 -0400 Subject: [PATCH 08/51] feat(multiverse): add private immutable Desktop Stop transport Keep exact-request retry owner/community private without repeating relay side effects. Exclude request/result ciphertext from search on fresh and upgraded databases. Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_stop.rs | 220 ++++++++++++++++++ crates/buzz-core/src/kind.rs | 9 + crates/buzz-core/src/lib.rs | 1 + crates/buzz-db/src/runtime/migration.rs | 27 ++- .../src/api/desktop_profile_postgres_tests.rs | 130 ++++++++++- crates/buzz-relay/src/handlers/event.rs | 32 +++ crates/buzz-relay/src/handlers/ingest.rs | 41 +++- migrations/0048_desktop_stop_fts.sql | 26 +++ schema/schema.sql | 2 +- 9 files changed, 474 insertions(+), 14 deletions(-) create mode 100644 crates/buzz-core/src/desktop_stop.rs create mode 100644 migrations/0048_desktop_stop_fts.sql diff --git a/crates/buzz-core/src/desktop_stop.rs b/crates/buzz-core/src/desktop_stop.rs new file mode 100644 index 00000000000..4f431655e99 --- /dev/null +++ b/crates/buzz-core/src/desktop_stop.rs @@ -0,0 +1,220 @@ +//! Immutable, owner-to-self Desktop Stop messages. Profiles are not authority. +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::kind::{KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT}; + +/// One agent on one Desktop in one community; never a caller-selected process. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct StopTarget { + /// Schema version. Old exact-run commands are not accepted here. + pub v: u8, + /// Canonical community WebSocket URL. + pub community: String, + /// Installation coordinate from the private Desktop inventory. + pub desktop: String, + /// Agent public key. The receiver independently verifies local ownership. + pub agent: String, +} + +/// Ordinary Desktop outcome, not a stronger process-termination certificate. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StopOutcome { + /// Ordinary Stop returned success. + Stopped, + /// Ordinary Stop returned an error. No automatic retry of the effect. + Failed, + /// Interrupted, stale or evicted request; never inferred success. + Unknown, +} + +/// Correlates exactly one immutable request with its Desktop's result. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct StopResult { + /// Original target, not mutable current routing. + pub target: StopTarget, + /// Signed request event ID. + pub request: String, + /// No diagnostic paths, credentials or process details on the wire. + pub outcome: StopOutcome, +} + +fn hex(value: &str, len: usize) -> bool { + value.len() == len + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Check public shape before storage, without decrypting content. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + let kind = event.kind.as_u16() as u32; + let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect(); + let result = kind == KIND_DESKTOP_STOP_RESULT; + if !matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) + || !(132..=4096).contains(&event.content.len()) + || tags.len() != if result { 2 } else { 1 } + || tags[0].len() != 2 + || tags[0][0] != "d" + || !hex(&tags[0][1], 32) + || (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64))) + { + return Err("invalid Desktop Stop envelope"); + } + Ok(()) +} + +fn sign(value: &T, keys: &Keys, kind: u32, tags: Vec) -> Result { + let ciphertext = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(value).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + EventBuilder::new(Kind::Custom(kind as u16), ciphertext) + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) +} + +fn read( + event: &Event, + keys: &Keys, + kind: u32, +) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop Stop signature")?; + if event.pubkey != keys.public_key() || event.kind.as_u16() as u32 != kind { + return Err("foreign Desktop Stop message".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop Stop decryption failed")?; + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop Stop payload".into()) +} + +impl StopTarget { + /// Validate the decrypted target against the captured community. + pub fn validate(&self, community: &str) -> Result<(), &'static str> { + if self.v != 1 + || self.community != community + || community.is_empty() + || community.len() > 512 + || !hex(&self.desktop, 32) + || !hex(&self.agent, 64) + || PublicKey::from_hex(&self.agent).is_err() + { + return Err("invalid Desktop Stop target"); + } + Ok(()) + } + + /// Produce a new immutable Stop. Transport retries must reuse this event. + pub fn sign(&self, keys: &Keys) -> Result { + self.validate(&self.community)?; + sign( + self, + keys, + KIND_DESKTOP_STOP, + vec![Tag::identifier(&self.desktop)], + ) + } + + /// Authenticate, decrypt and bind a Stop to its signed host coordinate. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + let target: Self = read(event, keys, KIND_DESKTOP_STOP)?; + target.validate(community)?; + if event.tags.identifier() != Some(target.desktop.as_str()) { + return Err("Desktop Stop routing mismatch".into()); + } + Ok(target) + } +} + +impl StopResult { + /// Sign the saved ordinary Stop result without exposing local diagnostics. + pub fn sign(&self, keys: &Keys) -> Result { + self.target.validate(&self.target.community)?; + if !hex(&self.request, 64) { + return Err("invalid Stop request ID".into()); + } + sign( + self, + keys, + KIND_DESKTOP_STOP_RESULT, + vec![ + Tag::identifier(&self.target.desktop), + Tag::parse(["e", &self.request]).map_err(|e| e.to_string())?, + ], + ) + } + + /// Check all correlation fields against the original authenticated request. + pub fn read( + event: &Event, + keys: &Keys, + request: &Event, + community: &str, + ) -> Result { + let target = StopTarget::read(request, keys, community)?; + let result: Self = read(event, keys, KIND_DESKTOP_STOP_RESULT)?; + if result.target != target + || result.request != request.id.to_hex() + || event.tags.identifier() != Some(target.desktop.as_str()) + || event.tags.iter().nth(1).and_then(|t| t.content()) != Some(result.request.as_str()) + { + return Err("Desktop Stop result correlation mismatch".into()); + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn private_immutable_stop_and_exact_result_correlation() { + let keys = Keys::generate(); + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }; + let request = target.sign(&keys).unwrap(); + assert_eq!( + StopTarget::read(&request, &keys, &target.community).unwrap(), + target + ); + assert!(!request.content.contains(&target.agent)); + assert!(StopTarget::read(&request, &Keys::generate(), &target.community).is_err()); + assert!(StopTarget::read(&request, &keys, "wss://other.example").is_err()); + let result = StopResult { + target: target.clone(), + request: request.id.to_hex(), + outcome: StopOutcome::Stopped, + } + .sign(&keys) + .unwrap(); + assert_eq!( + StopResult::read(&result, &keys, &request, &target.community) + .unwrap() + .outcome, + StopOutcome::Stopped + ); + let another = target.sign(&keys).unwrap(); + assert!(StopResult::read(&result, &keys, &another, &target.community).is_err()); + let mut tampered = request.clone(); + tampered.content.push('x'); + assert!(StopTarget::read(&tampered, &keys, &target.community).is_err()); + for kind in [KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT] { + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&kind)); + assert!(!crate::kind::is_parameterized_replaceable(kind)); + } + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4d309c3b609..5df974c36cb 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -125,6 +125,11 @@ pub const KIND_DESKTOP_OBSERVATION: u32 = 30181; /// Owner-private built-in runtime facts per Desktop, not agent readiness. pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182; +/// Immutable owner-private Desktop Stop request (not a replaceable profile). +pub const KIND_DESKTOP_STOP: u32 = 50180; +/// Owner-private ordinary Desktop Stop outcome, correlated by request event ID. +pub const KIND_DESKTOP_STOP_RESULT: u32 = 50181; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -141,6 +146,8 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_DESKTOP_PROFILE, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_CAPABILITIES, + KIND_DESKTOP_STOP, + KIND_DESKTOP_STOP_RESULT, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -673,6 +680,8 @@ pub const ALL_KINDS: &[u32] = &[ KIND_DESKTOP_PROFILE, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_CAPABILITIES, + KIND_DESKTOP_STOP, + KIND_DESKTOP_STOP_RESULT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index ee4bb5df789..4b3874be0f0 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod desktop_capabilities; pub mod desktop_observation; /// Owner-private Desktop display profiles. pub mod desktop_profile; +pub mod desktop_stop; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 7da5d0d0c68..35261123b61 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 47); + assert_eq!(migrations.len(), 48); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -911,7 +911,7 @@ mod postgres_tests { 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, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200)" + "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181)" )); // Public push-gateway authority is intentionally deployment-global and @@ -2394,6 +2394,8 @@ mod postgres_tests { (4_u8, 30_180_i32), (5_u8, 30_181_i32), (6_u8, 30_182_i32), + (7_u8, 50_180_i32), + (8_u8, 50_181_i32), ] { sqlx::query( "INSERT INTO events \ @@ -2429,7 +2431,9 @@ mod postgres_tests { (30_180, true), (30_181, true), (30_182, true), - (30_350, true) + (30_350, true), + (50_180, true), + (50_181, true) ] ); @@ -2454,7 +2458,9 @@ mod postgres_tests { (30_180, Some(true)), (30_181, Some(true)), (30_182, Some(true)), - (30_350, None) + (30_350, None), + (50_180, Some(true)), + (50_181, Some(true)) ] ); @@ -2493,6 +2499,15 @@ mod postgres_tests { "0047 must change brownfield capability FTS" ); + run_migrations_through(&pool, 47).await.unwrap(); + let stop_indexed: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE kind IN (50180, 50181) AND search_tsv IS NOT NULL", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stop_indexed, 2, "0048 must change brownfield Stop FTS"); + run_migrations(&pool) .await .expect("apply remaining migrations to populated database"); @@ -2511,7 +2526,9 @@ mod postgres_tests { (30_180, None), (30_181, None), (30_182, None), - (30_350, None) + (30_350, None), + (50_180, None), + (50_181, None) ] ); let gin_exists: bool = sqlx::query_scalar( diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs index 80f4619c583..ac35a0dd12e 100644 --- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -2,7 +2,10 @@ use super::postgres_tests::bridge_handler_test_state; use super::*; use axum::{body::Body, http::Request}; -use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; +use buzz_core::kind::{ + KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, + KIND_DESKTOP_STOP_RESULT, +}; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; use tower::ServiceExt; @@ -82,6 +85,13 @@ async fn desktop_capabilities_authenticated_owner_query_and_private_storage() { assert_private_desktop(KIND_DESKTOP_CAPABILITIES).await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_stop_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_STOP).await; + assert_private_desktop(KIND_DESKTOP_STOP_RESULT).await; +} + async fn assert_private_desktop(kind: u32) { let mut state = bridge_handler_test_state() .await @@ -103,7 +113,26 @@ async fn assert_private_desktop(kind: u32) { ) .unwrap(); let id = profile.id.clone(); - let event = if kind == KIND_DESKTOP_PROFILE { + let event = if matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { + let target = buzz_core::desktop_stop::StopTarget { + v: 1, + community: format!("wss://{host}"), + desktop: id.clone(), + agent: Keys::generate().public_key().to_hex(), + }; + let request = target.sign(&owner).unwrap(); + if kind == KIND_DESKTOP_STOP { + request + } else { + buzz_core::desktop_stop::StopResult { + target, + request: request.id.to_hex(), + outcome: buzz_core::desktop_stop::StopOutcome::Stopped, + } + .sign(&owner) + .unwrap() + } + } else if kind == KIND_DESKTOP_PROFILE { profile.sign(&owner).unwrap() } else if kind == KIND_DESKTOP_CAPABILITIES { buzz_core::desktop_capabilities::DesktopCapabilities::new(profile, vec![]) @@ -284,3 +313,100 @@ async fn aged_desktop_profile_retries_through_production_ingest_without_resignin assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); } } + +/// Same-ID transport retry must redeliver to a currently connected Desktop, +/// even when the first attempt arrived while it was absent. It cannot re-date, +/// re-store or expose the request to another owner/community. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { + use nostr::Filter; + use std::sync::atomic::AtomicU8; + use tokio::sync::{mpsc, Mutex}; + use tokio_util::sync::CancellationToken; + let mut state = bridge_handler_test_state() + .await + .expect("test infrastructure"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true; + let host = format!("stop-retry-{}.example", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let owner = Keys::generate(); + let outsider = Keys::generate(); + let target = buzz_core::desktop_stop::StopTarget { + v: 1, + community: format!("wss://{host}"), + desktop: uuid::Uuid::new_v4().simple().to_string(), + agent: Keys::generate().public_key().to_hex(), + }; + let prepared = target.sign(&owner).unwrap(); + let event = EventBuilder::new(prepared.kind, &prepared.content) + .tags(prepared.tags.iter().cloned()) + .custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 86_400)) + .sign_with_keys(&owner) + .unwrap(); + let raw = json!(event); + let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true); + let mut receivers = vec![]; + for (who, tenant) in [ + (&owner, community), + (&outsider, community), + ( + &owner, + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()), + ), + ] { + let conn = uuid::Uuid::new_v4(); + let (tx, rx) = mpsc::channel(16); + let (ctrl, _) = mpsc::channel(16); + state.conn_manager.register( + conn, + tx, + ctrl, + None, + CancellationToken::new(), + tenant, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(std::collections::HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn, who.public_key().to_bytes().to_vec()); + state.sub_registry.register_scoped( + tenant, + conn, + "stop".into(), + vec![Filter::new().kind(Kind::Custom(KIND_DESKTOP_STOP as u16))], + None, + ); + receivers.push(rx); + } + // Quiesce the asynchronous first dispatch before measuring the retry. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + for rx in &mut receivers { + drain(rx); + } + for _ in 0..2 { + let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["message"], "duplicate:"); + let frames = drain(&mut receivers[0]); + assert_eq!(frames.len(), 1, "{frames:?}"); + assert_eq!(frames[0][2], raw); + assert!(drain(&mut receivers[1]).is_empty()); + assert!(drain(&mut receivers[2]).is_empty()); + } + let (status, result) = post(&state, &host, "/events", &outsider, raw, true).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); + assert!(drain(&mut receivers[0]).is_empty()); + let (_, rows) = post(&state, &host, "/query", &owner, + json!([{"kinds":[KIND_DESKTOP_STOP],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await; + assert_eq!(rows.as_array().unwrap().len(), 1); +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 832b5678c9d..c4e4d1f86c9 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -337,6 +337,32 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } } +/// Retry delivery of an already stored Stop, without audit/workflow effects. +/// Admission and durable effect deduplication remain the Desktop's authority. +pub(crate) async fn redeliver_desktop_stop( + tenant: &TenantContext, + state: &Arc, + event: &nostr::Event, +) { + state.mark_local_event(tenant.community(), &event.id); + if let Err(error) = state + .pubsub + .publish_event(tenant, EventTopic::Global, event) + .await + { + state + .local_event_ids + .invalidate(&(tenant.community(), event.id.to_bytes())); + warn!(event_id = %event.id, %error, "Desktop Stop redelivery to peers failed"); + } + fan_out_event_to_local_subscribers( + state, + tenant.community(), + &StoredEvent::new(event.clone(), None), + ) + .await; +} + /// Schedule post-commit delivery/side effects for a stored event. /// /// This intentionally returns after only the bounded audit enqueue has completed: @@ -2220,6 +2246,12 @@ mod tests { assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await; } + #[tokio::test] + async fn desktop_stop_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_STOP).await; + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_STOP_RESULT).await; + } + #[tokio::test] async fn desktop_capabilities_delivers_to_author_only() { assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_CAPABILITIES).await; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9d4e9ad5dc4..112bea02085 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -36,7 +36,10 @@ use buzz_core::kind::{ RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; -use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; +use buzz_core::kind::{ + KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, + KIND_DESKTOP_STOP_RESULT, +}; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; @@ -437,7 +440,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 | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES => Ok(Scope::UsersWrite), + KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT => 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 @@ -661,6 +664,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES + | KIND_DESKTOP_STOP + | KIND_DESKTOP_STOP_RESULT | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -2171,15 +2176,20 @@ pub async fn ingest_event( result } -// Profiles and capability facts are durable records, not freshness signals. A Desktop may +// Profiles, capabilities and immutable Stop messages are not freshness signals. A Desktop may // first publish its immutable signed record long after an offline startup. // Only their past-age bound is waived; future drift and all other admission // checks still apply. Observation/presence kinds must retain their own window. fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool { const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900; event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS) - && (matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES) - || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) + && (matches!( + kind, + KIND_DESKTOP_PROFILE + | KIND_DESKTOP_CAPABILITIES + | KIND_DESKTOP_STOP + | KIND_DESKTOP_STOP_RESULT + ) || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) } async fn ingest_event_inner( @@ -2794,6 +2804,11 @@ async fn ingest_event_inner( } } + if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { + buzz_core::desktop_stop::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_DESKTOP_CAPABILITIES { buzz_core::desktop_capabilities::validate_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3232,6 +3247,12 @@ async fn ingest_event_inner( }; if !was_inserted { + // Stop is a one-shot owned by Desktop, not a replaceable projection. + // Explicit transport retry must reach a live receiver even after an ACK + // or its result was lost. Never replay history or repeat relay effects. + if kind_u32 == KIND_DESKTOP_STOP { + super::event::redeliver_desktop_stop(tenant, state, &stored_event.event).await; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -3345,6 +3366,8 @@ mod postgres_tests { for kind in [ KIND_DESKTOP_PROFILE, KIND_DESKTOP_CAPABILITIES, + KIND_DESKTOP_STOP, + KIND_DESKTOP_STOP_RESULT, 30181, KIND_PROFILE, KIND_EVENT_REMINDER, @@ -3362,7 +3385,13 @@ mod postgres_tests { ] { assert_eq!( timestamp_within_ingest_window(kind, timestamp, now), - if matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES) { + if matches!( + kind, + KIND_DESKTOP_PROFILE + | KIND_DESKTOP_CAPABILITIES + | KIND_DESKTOP_STOP + | KIND_DESKTOP_STOP_RESULT + ) { profile } else { ordinary diff --git a/migrations/0048_desktop_stop_fts.sql b/migrations/0048_desktop_stop_fts.sql new file mode 100644 index 00000000000..98116efbda9 --- /dev/null +++ b/migrations/0048_desktop_stop_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop Stop requests and results must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +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 (50180, 50181) 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 d78b3036d97..0a20e940285 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From a5b36742cde43280fd6ecf2db8d4aae0d7ed2927 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 15:59:26 -0400 Subject: [PATCH 09/51] feat(desktop): fence automatic launches after remote Stop Bind explicit Start permission before preflight and release it only after child registration. Reuse ordinary platform-specific pair Stop and refuse success for an unscoped live legacy child. Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/agents.rs | 62 +++++++-- desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/remote_stop.rs | 131 ++++++++++++++++++ .../src-tauri/src/managed_agents/restore.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 10 +- .../src/managed_agents/runtime/stop.rs | 2 +- .../src/managed_agents/runtime_commands.rs | 130 +++++++++++------ desktop/src/features/agents/AGENTS.md | 8 ++ desktop/src/features/agents/hooks.ts | 2 + .../lib/managedAgentControlActions.test.mjs | 10 +- .../agents/lib/managedAgentControlActions.ts | 7 +- .../agents/managedAgentRuntimeHooks.ts | 4 +- .../agents/ui/useManagedAgentActions.ts | 7 +- .../channels/ui/useMembersSidebarActions.ts | 1 + .../profile/ui/useAgentLifecycleActions.ts | 4 +- desktop/src/shared/api/tauriManagedAgents.ts | 10 +- 16 files changed, 326 insertions(+), 64 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/remote_stop.rs diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0ad7fd321c5..a3bc56882e2 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -160,15 +160,35 @@ pub(super) async fn start_local_agent_pairs_with_preflight( summarize_from_disk(app, record, &runtimes) } -pub(super) async fn start_local_agent_with_preflight( +enum LocalStartIntent { + Create, + Explicit, + Automatic, +} + +async fn start_local_agent_with_preflight( app: &AppHandle, state: &AppState, pubkey: &str, - allow_fresh_create_start: bool, + intent: LocalStartIntent, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, replay_floor_unix: Option, ) -> Result { + let launch_owner = workspace_owner_hex(state)?; + let launch_key = crate::managed_agents::ManagedAgentRuntimeKey::new( + pubkey, + &relay_ws_url_with_override(state), + )?; + let resume = if matches!(intent, LocalStartIntent::Explicit) { + Some(crate::managed_agents::remote_stop::capture_resume( + app, + &launch_key, + &launch_owner, + )?) + } else { + None + }; let record_snapshot = { let _store_guard = state .managed_agents_store_lock @@ -201,7 +221,12 @@ pub(super) async fn start_local_agent_with_preflight( &personas, &global, ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; + ensure_relay_mesh_for_record( + app, + mesh_model_id.as_deref(), + matches!(intent, LocalStartIntent::Create), + ) + .await?; // The mesh preflight above is the suspension window Projects callbacks // capture their scope against: a community switch during that await @@ -212,15 +237,21 @@ pub(super) async fn start_local_agent_with_preflight( // 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, + expected_relay_url.or(Some(launch_key.relay_url.as_str())), 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 workspace_owner = crate::relay::bind_expected_signer( + expected_signer_pubkey.or(Some(launch_owner.as_str())), + workspace_owner_hex(state)?, + )?; + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; let _store_guard = state .managed_agents_store_lock .lock() @@ -262,6 +293,7 @@ pub(super) async fn start_local_agent_with_preflight( Some(workspace_owner.as_str()), &workspace_relay_url, replay_floor_unix, + resume.as_ref(), )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { @@ -713,7 +745,16 @@ pub async fn create_managed_agent( // ── Phase 3b: local spawn (async preflight outside store lock) ─────────── let mut spawn_error = None; let agent = if input.spawn_after_create && input.backend == BackendKind::Local { - match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None, None).await + match start_local_agent_with_preflight( + &app, + &state, + &pubkey, + LocalStartIntent::Create, + None, + None, + None, + ) + .await { Ok(agent) => agent, Err(error) => { @@ -824,6 +865,7 @@ pub async fn start_managed_agent( expected_relay_url: Option, expected_signer_pubkey: Option, replay_floor_unix: Option, + explicit_start: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -920,7 +962,11 @@ pub async fn start_managed_agent( &app, &state, &pubkey, - false, + if explicit_start.unwrap_or(false) { + LocalStartIntent::Explicit + } else { + LocalStartIntent::Automatic + }, expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), replay_floor_unix, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a66f9c75ba2..e859344e4c7 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -32,6 +32,7 @@ mod process_lifecycle; pub(crate) mod readiness; pub(crate) mod reconcile; mod relay_mesh; +pub(crate) mod remote_stop; mod repos; mod restore; pub mod retention; diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs new file mode 100644 index 00000000000..818c7072064 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs @@ -0,0 +1,131 @@ +//! Durable no-auto-start fence shared by ordinary Desktop launch paths. +use super::retention::{open_retention_db, scoped_retention_db_path}; +use super::ManagedAgentRuntimeKey; +use rusqlite::{Connection, OptionalExtension}; +use tauri::{AppHandle, Manager}; + +fn schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_fence ( + agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL);") + .map_err(|e| e.to_string()) +} + +/// Explicit local Start captures the Stop fence before its asynchronous preflight. +/// Automatic starts and Restart continuations never receive this permission. +pub(crate) struct ResumeTicket { + previous: Option, +} + +pub(crate) fn capture_resume( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + owner: &str, +) -> Result { + let conn = connection(app, key, owner)?; + schema(&conn)?; + let previous = conn + .query_row( + "SELECT event_id FROM desktop_stop_fence WHERE agent=?1", + [&key.pubkey], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + Ok(ResumeTicket { previous }) +} + +fn connection( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + owner: &str, +) -> Result { + let path = + scoped_retention_db_path(&super::managed_agents_base_dir(app)?, &key.relay_url, owner); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + open_retention_db(&path) +} + +/// Every ordinary spawn passes here, including restore/config/reconcile. +/// Caller holds the existing transition lock through child registration. +pub(crate) fn check_launch( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + owner: Option<&str>, + resume: Option<&ResumeTicket>, +) -> Result<(), String> { + let state = app.state::(); + let current_owner = state.signing_keys()?.public_key().to_hex(); + if owner != Some(current_owner.as_str()) { + return Err("Desktop launch owner changed".into()); + } + let conn = connection(app, key, ¤t_owner)?; + schema(&conn)?; + let row: Option<(String, bool)> = conn + .query_row( + "SELECT event_id, blocked FROM desktop_stop_fence WHERE agent=?1", + [&key.pubkey], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + allow_launch(row.as_ref(), resume) +} + +fn allow_launch(row: Option<&(String, bool)>, resume: Option<&ResumeTicket>) -> Result<(), String> { + if let Some(ticket) = resume { + if ticket.previous.as_ref() != row.map(|(id, _)| id) { + return Err("A newer Stop interrupted this Start".into()); + } + } else if row.is_some_and(|(_, blocked)| *blocked) { + return Err( + "Stopped from another Desktop. Use Start agent to start it again explicitly.".into(), + ); + } + Ok(()) +} + +/// A failed spawn must not unblock config/restore. Commit only after the child +/// has its ordinary receipt and tracked handle, still under the transition lock. +pub(crate) fn finish_resume( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + owner: Option<&str>, + ticket: Option<&ResumeTicket>, +) -> Result<(), String> { + if ticket.is_none() { + return Ok(()); + } + let owner = owner.ok_or("Desktop launch owner unavailable")?; + check_launch(app, key, Some(owner), ticket)?; + connection(app, key, owner)? + .execute( + "UPDATE desktop_stop_fence SET blocked=0 WHERE agent=?1", + [&key.pubkey], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() { + let stopped = ("stop-a".to_owned(), true); + let resumed = ("stop-a".to_owned(), false); + let new_stop = ("stop-b".to_owned(), true); + assert!(allow_launch(None, None).is_ok()); + assert!(allow_launch(Some(&stopped), None).is_err()); + assert!(allow_launch(Some(&resumed), None).is_ok()); + let ticket = ResumeTicket { + previous: Some("stop-a".to_owned()), + }; + assert!(allow_launch(Some(&stopped), Some(&ticket)).is_ok()); + assert!(allow_launch(Some(&new_stop), Some(&ticket)).is_err()); + let before_any_stop = ResumeTicket { previous: None }; + assert!(allow_launch(Some(&stopped), Some(&before_any_stop)).is_err()); + assert!(allow_launch(None, Some(&before_any_stop)).is_ok()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 5b79ccac27f..66aa95cba74 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -345,6 +345,7 @@ pub async fn restore_managed_agents_on_launch( true, owner_hex_ref, None, + None, ) }) { Ok(process) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b8d586b32af..1eec6eee979 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -31,7 +31,7 @@ mod setup_payload; use setup_payload::apply_setup_payload_env; mod stop; -pub(crate) use stop::managed_agent_runtime_keys; +pub(crate) use stop::{managed_agent_runtime_keys, stop_managed_agent_pair}; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; @@ -451,7 +451,10 @@ pub fn spawn_agent_child( lazy: bool, owner_hex: Option<&str>, replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, ) -> Result { + let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + super::remote_stop::check_launch(app, &key, owner_hex, resume)?; if let Some(error) = spawn_key_refusal(record) { return Err(error); } @@ -881,6 +884,7 @@ pub fn start_managed_agent_process( owner_hex: Option<&str>, workspace_relay: &crate::relay::ScopedWorkspaceRelay, replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; if let Some(runtime) = runtimes.get_mut(&key) { @@ -907,6 +911,7 @@ pub fn start_managed_agent_process( false, owner_hex, replay_floor_unix, + resume, )?; let now = now_iso(); let receipt = super::ManagedAgentRuntimeReceipt { @@ -928,7 +933,8 @@ pub fn start_managed_agent_process( record.last_error = None; record.last_error_code = None; - runtimes.insert(key, ManagedAgentPairRuntime::starting(process)); + runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); + super::remote_stop::finish_resume(app, &key, owner_hex, resume)?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 7b8ded7926d..0c13937ff27 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -37,7 +37,7 @@ pub(crate) fn managed_agent_runtime_relay_urls( /// runtime is reinserted so the pair stays visible and stoppable instead of /// becoming an invisible orphan. Touches no other pair for the agent and /// does no record-level stop bookkeeping — callers own that. -fn stop_managed_agent_pair( +pub(crate) fn stop_managed_agent_pair( app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index ba0f91c9f7a..eacb7ba6bf6 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, 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, }; use crate::app_state::AppState; @@ -229,16 +228,24 @@ pub(crate) fn start_managed_agent_runtime_pair_lazy( relay_url: String, app: AppHandle, ) -> Result { - start_pair(pubkey, relay_url, true, None, app) + start_pair(pubkey, relay_url, true, None, false, app) } #[tauri::command] pub fn start_managed_agent_runtime( pubkey: String, relay_url: String, + explicit_start: Option, app: AppHandle, ) -> Result { - start_managed_agent_runtime_pair_lazy(pubkey, relay_url, app) + start_pair( + pubkey, + relay_url, + true, + None, + explicit_start.unwrap_or(false), + app, + ) } fn start_pair( @@ -246,6 +253,7 @@ fn start_pair( relay_url: String, lazy: bool, expected_updated_at: Option<&str>, + explicit_start: bool, app: AppHandle, ) -> Result { let state = app.state::(); @@ -288,8 +296,24 @@ fn start_pair( .lock() .ok() .map(|keys| keys.public_key().to_hex()); - let mut process = - spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?; + let resume = if explicit_start { + Some(super::remote_stop::capture_resume( + &app, + &key, + owner.as_deref().ok_or("Desktop owner unavailable")?, + )?) + } else { + None + }; + let mut process = spawn_agent_child( + &app, + record, + &key.relay_url, + lazy, + owner.as_deref(), + None, + resume.as_ref(), + )?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { key: key.clone(), @@ -308,6 +332,7 @@ fn start_pair( record.last_stopped_at = None; record.last_error = None; runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); + super::remote_stop::finish_resume(&app, &key, owner.as_deref(), resume.as_ref())?; let status = status_for(&app, record, &key, runtimes.get(&key), None); drop(runtimes); save_managed_agents(&app, &records)?; @@ -326,6 +351,16 @@ pub fn stop_managed_agent_runtime( .managed_agent_runtime_transition .lock() .map_err(|e| e.to_string())?; + stop_pair_locked(pubkey, relay_url, app.clone()) +} + +// Caller owns managed_agent_runtime_transition for the whole admission/effect. +pub(crate) fn stop_pair_locked( + pubkey: String, + relay_url: String, + app: AppHandle, +) -> Result { + let state = app.state::(); let _store = state .managed_agents_store_lock .lock() @@ -337,42 +372,27 @@ pub fn stop_managed_agent_runtime( .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); - } - } + if runtimes.contains_key(&key) { + // Use ordinary Desktop Stop, including its platform-specific child/job + // ownership. Remote control must not grow a second teardown contract. + super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?; } 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)?; } + // Old scalar records have no community-bound receipt. Do not erase a live + // child or claim success for it when this request cannot establish scope. + reject_unscoped_live_child( + record.runtime_pid.filter(|pid| process_is_running(*pid)), + runtimes.values().map(|runtime| runtime.child.id()), + )?; super::remove_agent_runtime_receipt(&app, &key); state.clear_agent_session_cache(&key); - record.runtime_pid = None; + if record + .runtime_pid + .is_some_and(|pid| !process_is_running(pid)) + { + 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); @@ -382,6 +402,16 @@ pub fn stop_managed_agent_runtime( Ok(status) } +fn reject_unscoped_live_child( + live_pid: Option, + tracked: impl Iterator, +) -> Result<(), String> { + if live_pid.is_some_and(|pid| !tracked.into_iter().any(|other| other == pid)) { + return Err("Legacy runtime is not bound to this community; use local Desktop Stop".into()); + } + Ok(()) +} + #[tauri::command] pub fn restart_managed_agent_runtime( pubkey: String, @@ -389,7 +419,7 @@ pub fn restart_managed_agent_runtime( app: AppHandle, ) -> Result { stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?; - start_pair(pubkey, relay_url, true, None, app) + start_pair(pubkey, relay_url, true, None, false, app) } /// Probe whether this agent can operate on `requested_relay_url`. @@ -513,6 +543,7 @@ pub async fn reconcile_managed_agent_runtimes( key.relay_url.clone(), true, Some(&record.updated_at), + false, app.clone(), ) { Ok(mut status) => { @@ -732,3 +763,16 @@ mod tests { assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err()); } } + +#[cfg(test)] +mod stop_scope_tests { + use super::reject_unscoped_live_child; + + #[test] + fn live_legacy_child_cannot_be_erased_or_reported_stopped() { + assert!(reject_unscoped_live_child(Some(12), [].into_iter()).is_err()); + assert!(reject_unscoped_live_child(Some(12), [13].into_iter()).is_err()); + assert!(reject_unscoped_live_child(Some(12), [12].into_iter()).is_ok()); + assert!(reject_unscoped_live_child(None, [13].into_iter()).is_ok()); + } +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index dfb9c0ed494..1af98978be4 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -308,6 +308,14 @@ with a TypeScript lookup table or an id comparison in a component. 17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. +## Desktop Stop launch fence + +All local spawn paths consume the durable Stop fence at the shared native +spawn boundary. Only a deliberate **Start agent** action can supersede that +fence; config/restore/reconcile and Restart continuations cannot. Explicit Start +captures its fence before preflight and fails if a newer Stop arrives. Fence +release happens only after the new child has its receipt and tracked handle. + ## Channel-only runtime controls Desktop observer controls identify a channel, not a thread session. The harness diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ec1ccd262e8..8f2fe3ca1c0 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -595,6 +595,7 @@ export function useStartManagedAgentMutation() { expectedRelayUrl?: string; expectedSignerPubkey?: string; replayFloorUnix?: number; + explicitStart?: boolean; }, ) => typeof input === "string" @@ -603,6 +604,7 @@ export function useStartManagedAgentMutation() { expectedRelayUrl: input.expectedRelayUrl, expectedSignerPubkey: input.expectedSignerPubkey, replayFloorUnix: input.replayFloorUnix, + explicitStart: input.explicitStart, }), onSuccess: (updated) => { queryClient.setQueryData( diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2e..e2efef08c33 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -56,7 +56,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, + explicitStart: true, + }); // Backend preflight failures (e.g. no live serve target) propagate as-is. await assert.rejects( @@ -78,7 +81,10 @@ test("ordinary local agents still start normally", async () => { calledWith = pubkey; }, }); - assert.equal(calledWith, "deadbeef".repeat(8)); + assert.deepEqual(calledWith, { + pubkey: "deadbeef".repeat(8), + explicitStart: true, + }); }); // --- respawnManagedAgentWithRules: stop→clear→start boundary tests ----------- diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index aaf10075e0d..d0fea69a062 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -8,7 +8,10 @@ type DeleteManagedAgentInput = { forceRemoteDelete?: boolean; }; -type StartManagedAgent = (pubkey: string) => Promise; +export type ManagedAgentStartInput = + | string + | { pubkey: string; explicitStart: true }; +type StartManagedAgent = (input: ManagedAgentStartInput) => Promise; type StopManagedAgent = (pubkey: string) => Promise; type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise; @@ -82,7 +85,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({ pubkey: agent.pubkey, explicitStart: true }); } export async function respawnManagedAgentWithRules({ diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 96a3abc78d4..21dfe9a94fe 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -185,10 +185,12 @@ export function useManagedAgentRuntimeAction() { action, pubkey, relayUrl, + explicitStart = false, }: { action: "start" | "stop" | "restart"; pubkey: string; relayUrl: string; + explicitStart?: boolean; }) => { if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl); if (action === "restart") { @@ -200,7 +202,7 @@ export function useManagedAgentRuntimeAction() { startManagedAgentRuntime, ); } - return startManagedAgentRuntime(pubkey, relayUrl); + return startManagedAgentRuntime(pubkey, relayUrl, explicitStart); }, onSuccess: (runtime, { action }) => { // For stop-only: clear stale working badges immediately. The restart diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 9c06044c5a9..5f5cb277457 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -433,10 +433,11 @@ export function useManagedAgentActions() { stopMutation.isPending || startOnLaunchMutation.isPending || deleteMutation.isPending; - const startingAgentPubkey = - startMutation.isPending && typeof startMutation.variables === "string" + const startingAgentPubkey = startMutation.isPending + ? typeof startMutation.variables === "string" ? startMutation.variables - : null; + : (startMutation.variables?.pubkey ?? null) + : null; return { relayAgentsQuery, diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index ced4836d6b4..63a64899b61 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -176,6 +176,7 @@ export function useMembersSidebarActions({ action, pubkey: agent.pubkey, relayUrl, + explicitStart: action === "start", }); setActionNoticeMessage( action === "stop" diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 745df71768d..0b2e79ab6e5 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -28,7 +28,9 @@ export function useAgentLifecycleActions({ channels: readonly Channel[] | undefined; managedAgent: ManagedAgent | undefined; relayAgents: readonly RelayAgent[] | undefined; - startManagedAgent: (pubkey: string) => Promise; + startManagedAgent: ( + input: import("@/features/agents/lib/managedAgentControlActions").ManagedAgentStartInput, + ) => Promise; stopManagedAgent: (pubkey: string) => Promise; }) { const handleAgentPrimaryAction = React.useCallback(async () => { diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index ed7e053f259..3d33e925c13 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -24,6 +24,8 @@ export async function startManagedAgent( * long the spawn takes. Local spawns receive it as process env; provider * deploys carry it in the payload's launch.policy_env. */ replayFloorUnix?: number; + /** Only a deliberate Start button may supersede a remote Stop. */ + explicitStart?: boolean; }, ): Promise { const response = await invokeTauri("start_managed_agent", { @@ -31,6 +33,7 @@ export async function startManagedAgent( expectedRelayUrl: options?.expectedRelayUrl ?? null, expectedSignerPubkey: options?.expectedSignerPubkey ?? null, replayFloorUnix: options?.replayFloorUnix ?? null, + explicitStart: options?.explicitStart ?? false, }); return fromRawManagedAgent(response); } @@ -81,8 +84,13 @@ export async function listManagedAgentRuntimes(): Promise< export async function startManagedAgentRuntime( pubkey: string, relayUrl: string, + explicitStart = false, ): Promise { - return invokeTauri("start_managed_agent_runtime", { pubkey, relayUrl }); + return invokeTauri("start_managed_agent_runtime", { + pubkey, + relayUrl, + explicitStart, + }); } export async function stopManagedAgentRuntime( From 18a0d17070d24988262f7f02c6cd49b6cdb5666d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 15:59:52 -0400 Subject: [PATCH 10/51] feat(desktop): authenticate and durably consume remote Stop Bind owner delegation, installation, agent and community before ordinary Stop. Persist admission before effects and retain exact signed outcomes for retries across reopening and bounded eviction. Signed-off-by: Logan Johnson --- .../src-tauri/src/commands/desktop_stop.rs | 145 +++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 3 + .../src/managed_agents/remote_stop.rs | 299 +++++++++++++++++- desktop/src/features/agents/AGENTS.md | 11 +- 5 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 desktop/src-tauri/src/commands/desktop_stop.rs diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs new file mode 100644 index 00000000000..11ab7fe4e14 --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_stop.rs @@ -0,0 +1,145 @@ +//! Native owner/host validation and ordinary Stop; no keys cross IPC. +use super::desktop_profiles::{prepare, scope}; +use crate::{ + app_state::AppState, + managed_agents::{self, remote_stop, retention::open_retention_db}, +}; +use buzz_core_pkg::{ + desktop_profile::DesktopProfile, + desktop_stop::{StopOutcome, StopResult, StopTarget}, +}; +use nostr::{Event, JsonUtil, PublicKey}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; + +fn local_id( + conn: &mut rusqlite::Connection, + scope: &managed_agents::retention::RetentionScope, +) -> Result { + let saved = prepare(conn, scope)?; + let event: Event = serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?; + Ok(DesktopProfile::read( + &event, + &scope.owner_keys, + scope.relay_url.trim_end_matches('/'), + )? + .id) +} + +/// Persist exact signed bytes before the UI sends a new Stop. No boot replay. +#[tauri::command] +pub fn prepare_desktop_stop( + app: AppHandle, + owner: String, + community: String, + desktop: String, + agent: String, +) -> Result { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + let event = StopTarget { + v: 1, + community, + desktop, + agent, + } + .sign(&scope.owner_keys)?; + let conn = open_retention_db(&scope.db_path)?; + // Only the current UI operation needs retry bytes; retained receiver fences + // and results are separate. Nothing automatically drains this slot. + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_outgoing (slot INTEGER PRIMARY KEY CHECK(slot=1), raw TEXT NOT NULL)") + .map_err(|e| e.to_string())?; + conn.execute("INSERT INTO desktop_stop_outgoing VALUES (1, ?1) ON CONFLICT(slot) DO UPDATE SET raw=excluded.raw", [event.as_json()]) + .map_err(|e| e.to_string())?; + Ok(event) +} + +/// Called only for live owner-private delivery. Reopening never fetches commands. +#[tauri::command] +pub async fn receive_desktop_stop( + app: AppHandle, + owner: String, + community: String, + event: Event, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let scope = scope(&app, &state, &owner, &community)?; + let target = StopTarget::read(&event, &scope.owner_keys, &community)?; + let mut conn = open_retention_db(&scope.db_path)?; + let desktop = local_id(&mut conn, &scope)?; + if desktop != target.desktop { + return Ok(None); + } + // Local possession alone is insufficient after an account switch: + // verify the stored agent's owner delegation against the request author. + let owned = { + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = managed_agents::load_managed_agents(&app)?; + records + .iter() + .find(|r| r.pubkey == target.agent) + .is_some_and(|r| { + r.backend == managed_agents::BackendKind::Local + && r.auth_tag + .as_deref() + .and_then(|tag| { + let key = PublicKey::from_hex(&target.agent).ok()?; + buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &key).ok() + }) + .is_some_and(|key| key.to_hex() == owner) + }) + }; + remote_stop::receive( + &mut conn, + &event, + &scope.owner_keys, + &community, + &desktop, + owned, + |target| { + managed_agents::stop_pair_locked( + target.agent.clone(), + community.clone(), + app.clone(), + ) + .map(|_| ()) + }, + ) + }) + .await + .map_err(|e| format!("Desktop Stop task failed: {e}"))? +} + +/// Result queries never dispatch/replay a request. Missing means Unknown. +#[tauri::command] +pub fn read_desktop_stop_results( + app: AppHandle, + owner: String, + community: String, + request: Event, + events: Vec, +) -> Result { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + StopTarget::read(&request, &scope.owner_keys, &community)?; + if events.len() > 16 { + return Err("too many Desktop Stop results".into()); + } + let mut outcome = StopOutcome::Unknown; + for event in events { + let result = StopResult::read(&event, &scope.owner_keys, &request, &community)?; + // Persisted terminal result beats a later Unknown after bounded eviction. + if result.outcome != StopOutcome::Unknown { + outcome = result.outcome; + } + } + Ok(json!(outcome)) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 0cb37bf7e02..5a5c2566ac1 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -20,6 +20,7 @@ mod channels; mod clipboard; mod desktop_capabilities; mod desktop_profiles; +mod desktop_stop; mod dms; mod engrams; mod export_util; @@ -96,6 +97,7 @@ pub use channels::*; pub use clipboard::*; pub use desktop_capabilities::*; pub use desktop_profiles::*; +pub use desktop_stop::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f794243354d..2eb43d10670 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -553,6 +553,9 @@ pub fn run() { title_bar_double_click, get_identity, prepare_desktop_profile, + prepare_desktop_stop, + receive_desktop_stop, + read_desktop_stop_results, read_desktop_profiles, prepare_desktop_observation, read_desktop_observations, diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs index 818c7072064..93e3e9b5adc 100644 --- a/desktop/src-tauri/src/managed_agents/remote_stop.rs +++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs @@ -1,15 +1,120 @@ -//! Durable no-auto-start fence shared by ordinary Desktop launch paths. +//! Durable Stop admission. Compact outcomes never compact the per-agent fence. +use buzz_core_pkg::desktop_stop::{StopOutcome, StopResult, StopTarget}; +use nostr::{Event, JsonUtil, Keys}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use tauri::{AppHandle, Manager}; + use super::retention::{open_retention_db, scoped_retention_db_path}; use super::ManagedAgentRuntimeKey; -use rusqlite::{Connection, OptionalExtension}; -use tauri::{AppHandle, Manager}; + +const HISTORY_LIMIT: i64 = 256; fn schema(conn: &Connection) -> Result<(), String> { conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_fence ( - agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL);") + agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS desktop_stop_results ( + id TEXT PRIMARY KEY, raw TEXT NOT NULL);") .map_err(|e| e.to_string()) } +/// Persist admission before effect; duplicates/interruption never repeat Stop. +pub(crate) fn admit( + conn: &mut Connection, + request: &Event, + target: &StopTarget, +) -> Result { + schema(conn)?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + let previous: Option<(u64, String)> = tx + .query_row( + "SELECT stamp, event_id FROM desktop_stop_fence WHERE agent = ?1", + [&target.agent], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + let id = request.id.to_hex(); + let stamp = request.created_at.as_secs(); + if previous.is_some_and(|(time, key)| time > stamp || (time == stamp && key <= id)) { + return Ok(false); + } + tx.execute("INSERT INTO desktop_stop_fence VALUES (?1, ?2, ?3, 1) + ON CONFLICT(agent) DO UPDATE SET stamp=excluded.stamp, event_id=excluded.event_id, blocked=1", + params![target.agent, stamp, id]).map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(true) +} + +pub(crate) fn saved_result(conn: &Connection, id: &str) -> Result, String> { + schema(conn)?; + conn.query_row( + "SELECT raw FROM desktop_stop_results WHERE id=?1", + [id], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string()) +} + +pub(crate) fn save_result(conn: &mut Connection, id: &str, raw: &str) -> Result<(), String> { + schema(conn)?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + tx.execute( + "INSERT OR IGNORE INTO desktop_stop_results VALUES (?1, ?2)", + params![id, raw], + ) + .map_err(|e| e.to_string())?; + tx.execute( + "DELETE FROM desktop_stop_results WHERE rowid NOT IN + (SELECT rowid FROM desktop_stop_results ORDER BY rowid DESC LIMIT ?1)", + [HISTORY_LIMIT], + ) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string()) +} + +/// Authenticate and durably consume a live request before invoking ordinary Stop. +/// The caller holds the runtime transition lock across this entire operation. +pub(crate) fn receive( + conn: &mut Connection, + request: &Event, + keys: &Keys, + community: &str, + desktop: &str, + owned: bool, + stop: impl FnOnce(&StopTarget) -> Result<(), String>, +) -> Result, String> { + let target = StopTarget::read(request, keys, community)?; + if target.desktop != desktop { + return Ok(None); + } + let id = request.id.to_hex(); + if let Some(raw) = saved_result(conn, &id)? { + let result = Event::from_json(raw).map_err(|e| e.to_string())?; + StopResult::read(&result, keys, request, community)?; + return Ok(Some(result)); + } + let outcome = if !owned { + StopOutcome::Failed + } else if admit(conn, request, &target)? { + outcome(stop(&target)) + } else { + StopOutcome::Unknown + }; + let result = StopResult { + target, + request: id.clone(), + outcome, + } + .sign(keys)?; + save_result(conn, &id, &result.as_json())?; + Ok(Some(result)) +} + /// Explicit local Start captures the Stop fence before its asynchronous preflight. /// Automatic starts and Restart continuations never receive this permission. pub(crate) struct ResumeTicket { @@ -108,9 +213,27 @@ pub(crate) fn finish_resume( Ok(()) } +/// Expose outcomes without mistaking a missing record for success. +pub(crate) fn outcome(stopped: Result<(), String>) -> StopOutcome { + if stopped.is_ok() { + StopOutcome::Stopped + } else { + StopOutcome::Failed + } +} + #[cfg(test)] mod tests { use super::*; + use nostr::{EventBuilder, Keys, Timestamp}; + fn request(keys: &Keys, target: &StopTarget, time: u64) -> Event { + let e = target.sign(keys).unwrap(); + EventBuilder::new(e.kind, e.content) + .tags(e.tags.to_vec()) + .custom_created_at(Timestamp::from(time)) + .sign_with_keys(keys) + .unwrap() + } #[test] fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() { let stopped = ("stop-a".to_owned(), true); @@ -128,4 +251,172 @@ mod tests { assert!(allow_launch(Some(&stopped), Some(&before_any_stop)).is_err()); assert!(allow_launch(None, Some(&before_any_stop)).is_ok()); } + + #[test] + fn saved_result_is_immutable_and_duplicate_after_interruption_is_unknown() { + let mut conn = Connection::open_in_memory().unwrap(); + let keys = Keys::generate(); + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }; + let event = request(&keys, &target, 100); + assert!(admit(&mut conn, &event, &target).unwrap()); + // A crash between admission and recording an outcome never reexecutes. + assert!(saved_result(&conn, &event.id.to_hex()).unwrap().is_none()); + assert!(!admit(&mut conn, &event, &target).unwrap()); + save_result(&mut conn, &event.id.to_hex(), "original bytes").unwrap(); + save_result(&mut conn, &event.id.to_hex(), "replacement").unwrap(); + assert_eq!( + saved_result(&conn, &event.id.to_hex()).unwrap().as_deref(), + Some("original bytes") + ); + assert_eq!( + outcome(Err("ordinary Stop failed".into())), + StopOutcome::Failed + ); + assert_eq!(outcome(Ok(())), StopOutcome::Stopped); + } + + #[test] + fn receiver_authenticates_routes_and_returns_exact_saved_result_without_effect() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("receiver.db"); + let mut conn = open_retention_db(&path).unwrap(); + let keys = Keys::generate(); + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }; + let event = request(&keys, &target, 100); + let no_effect = |_: &StopTarget| panic!("must not invoke ordinary Stop"); + let foreign = Keys::generate(); + for (signer, community, host, rejected) in [ + ( + &foreign, + target.community.as_str(), + target.desktop.as_str(), + true, + ), + (&keys, "wss://other.example", target.desktop.as_str(), true), + ( + &keys, + target.community.as_str(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + false, + ), + ] { + let result = receive(&mut conn, &event, signer, community, host, true, no_effect); + if rejected { + assert!(result.is_err()); + } else { + assert!(result.unwrap().is_none()); + } + } + let receive_owned = |conn: &mut Connection, event: &Event, owned, stop| { + receive( + conn, + event, + &keys, + &target.community, + &target.desktop, + owned, + stop, + ) + .unwrap() + .unwrap() + }; + let fail: fn(&StopTarget) -> Result<(), String> = |_| Err("ordinary Stop error".into()); + let no_effect: fn(&StopTarget) -> Result<(), String> = no_effect; + // The first effect succeeds. The reopened retry must return its exact + // signed bytes without invoking the callback at all. + let mut effects = 0; + let result = receive( + &mut conn, + &event, + &keys, + &target.community, + &target.desktop, + true, + |actual| { + assert_eq!(actual, &target); + effects += 1; + Ok(()) + }, + ) + .unwrap() + .unwrap(); + assert_eq!(effects, 1); + let assert_outcome = |result: &Event, request: &Event, expected| { + assert_eq!( + StopResult::read(result, &keys, request, &target.community) + .unwrap() + .outcome, + expected + ); + }; + assert_outcome(&result, &event, StopOutcome::Stopped); + drop(conn); + let mut conn = open_retention_db(&path).unwrap(); + let duplicate = receive_owned(&mut conn, &event, true, no_effect); + assert_eq!(result.as_json(), duplicate.as_json()); + let next = request(&keys, &target, 101); + let failed = receive_owned(&mut conn, &next, true, fail); + assert_outcome(&failed, &next, StopOutcome::Failed); + assert_eq!( + failed.as_json(), + receive_owned(&mut conn, &next, true, no_effect).as_json() + ); + let unowned = request(&keys, &target, 102); + let denied = receive_owned(&mut conn, &unowned, false, no_effect); + assert_outcome(&denied, &unowned, StopOutcome::Failed); + assert_eq!( + conn.query_row( + "SELECT event_id FROM desktop_stop_fence WHERE agent=?1", + [&target.agent], + |r| r.get::<_, String>(0) + ) + .unwrap(), + next.id.to_hex() + ); + let interrupted = request(&keys, &target, 103); + assert!(admit(&mut conn, &interrupted, &target).unwrap()); + let unknown = receive_owned(&mut conn, &interrupted, true, no_effect); + assert_outcome(&unknown, &interrupted, StopOutcome::Unknown); + } + + #[test] + fn durable_fence_survives_outcome_eviction_and_accepts_fresh_stop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("stop.db"); + let mut conn = open_retention_db(&path).unwrap(); + let keys = Keys::generate(); + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }; + let first = request(&keys, &target, 100); + assert!(admit(&mut conn, &first, &target).unwrap()); + assert!(!admit(&mut conn, &first, &target).unwrap()); + for i in 0..HISTORY_LIMIT + 2 { + save_result(&mut conn, &format!("{i}"), "result").unwrap(); + } + drop(conn); + let mut conn = open_retention_db(&path).unwrap(); + assert!(!admit(&mut conn, &first, &target).unwrap()); + assert!(admit(&mut conn, &request(&keys, &target, 101), &target).unwrap()); + assert!(!admit(&mut conn, &request(&keys, &target, 99), &target).unwrap()); + let a = request(&keys, &target, 102); + let b = request(&keys, &target, 102); + let (low, high) = if a.id < b.id { (a, b) } else { (b, a) }; + assert!(admit(&mut conn, &high, &target).unwrap()); + assert!(admit(&mut conn, &low, &target).unwrap()); + assert!(!admit(&mut conn, &high, &target).unwrap()); + } } diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 1af98978be4..c196e8653c0 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -308,13 +308,18 @@ with a TypeScript lookup table or an id comparison in a component. 17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. -## Desktop Stop launch fence +## Remote Desktop Stop +Native IPC accepts an owner-private, explicitly selected agent+Desktop Stop, +not inferred agent location. The relay redelivers stored Stop duplicates without +repeating relay side effects. +The receiver returns saved results or Unknown, never repeats a consumed Stop. +Native owner-delegation and community checks +precede durable admission and ordinary pair Stop. A delivery ACK is not success. All local spawn paths consume the durable Stop fence at the shared native spawn boundary. Only a deliberate **Start agent** action can supersede that fence; config/restore/reconcile and Restart continuations cannot. Explicit Start -captures its fence before preflight and fails if a newer Stop arrives. Fence -release happens only after the new child has its receipt and tracked handle. +captures its fence before preflight and fails if a newer Stop arrives. ## Channel-only runtime controls From 17e08d74657370c59d054d2eb3492b1b098de1c4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 16:00:34 -0400 Subject: [PATCH 11/51] feat(desktop): add scope-bound Stop client and exact-request retry Signed-off-by: Logan Johnson --- .../src/features/agents/desktopStop.test.mjs | 237 ++++++++++++++++++ desktop/src/features/agents/desktopStop.ts | 146 +++++++++++ 2 files changed, 383 insertions(+) create mode 100644 desktop/src/features/agents/desktopStop.test.mjs create mode 100644 desktop/src/features/agents/desktopStop.ts diff --git a/desktop/src/features/agents/desktopStop.test.mjs b/desktop/src/features/agents/desktopStop.test.mjs new file mode 100644 index 00000000000..015a39480e2 --- /dev/null +++ b/desktop/src/features/agents/desktopStop.test.mjs @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + prepareStop, + readStopOutcome, + receiveStops, + sendStop, +} from "./desktopStop.ts"; + +const scope = { owner: "owner", community: "wss://one.example" }; +const request = { + id: "request", + kind: 50180, + pubkey: scope.owner, + tags: [["d", "desktop"]], +}; +const result = { + id: "result", + kind: 50181, + pubkey: scope.owner, + tags: [["e", request.id]], +}; +const tick = () => new Promise((resolve) => setImmediate(resolve)); +function fixture() { + let epoch = 0; + let live; + let closed = false; + let effect = 0; + let outcome; + let failResult = false; + const saved = new Map(); + const stored = new Map(); + const publishes = []; + const errors = []; + const ipc = async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + if (command === "prepare_desktop_stop") return request; + if (command === "receive_desktop_stop") { + if (!saved.has(args.event.id)) { + effect++; + saved.set(args.event.id, result); + } + return saved.get(args.event.id); + } + if (command === "read_desktop_stop_results") { + assert.equal(args.request, request); + return args.events.includes(result) ? "stopped" : "unknown"; + } + throw Error(command); + }; + const relay = { + getSessionEpoch: () => epoch, + publishEvent: async (event, _timeout, _failure, check) => { + check(); + publishes.push(event); + if (event.kind === 50180) { + stored.set(event.id, event); + // Real relay contract: same immutable Stop is explicitly redelivered. + live?.(event); + } else { + if (failResult) throw Error("lost result publish"); + outcome = event; + } + }, + fetchEvents: async (filter) => { + assert.deepEqual(filter, { + kinds: [50181], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + return outcome ? [outcome] : []; + }, + subscribeLive: async (filter, onEvent, ready) => { + assert.deepEqual(filter, { + kinds: [50180], + authors: [scope.owner], + limit: 0, + }); + live = onEvent; + ready("eose"); + return () => { + live = undefined; + closed = true; + }; + }, + }; + return { + ipc, + relay, + publishes, + errors, + stored, + saved, + effect: () => effect, + closed: () => closed, + deliver: () => live?.(request), + switchScope: () => { + epoch++; + }, + failResult: (value) => { + failResult = value; + }, + }; +} + +test("lost delivery/result recovers only on explicit exact-byte retry, not history replay", async () => { + const f = fixture(); + const prepared = await prepareStop( + scope, + "desktop", + "agent", + () => true, + f.ipc, + f.relay, + ); + await sendStop(scope, prepared, () => true, f.relay); // target absent + assert.equal(f.effect(), 0); + const close = await receiveStops( + scope, + () => true, + (e) => f.errors.push(e), + f.ipc, + f.relay, + ); + assert.equal( + f.effect(), + 0, + "opening receiver cannot dispatch stored requests", + ); + assert.equal( + await readStopOutcome(scope, request, () => true, f.ipc, f.relay), + "unknown", + ); + assert.equal(f.effect(), 0, "status is read-only"); + f.failResult(true); + await sendStop(scope, prepared, () => true, f.relay); + await tick(); + assert.equal(f.effect(), 1); + assert.equal(f.errors.length, 1); + f.failResult(false); + await sendStop(scope, prepared, () => true, f.relay); + await tick(); + assert.equal( + f.effect(), + 1, + "consumed request returns saved outcome without effect", + ); + assert.equal( + await readStopOutcome(scope, request, () => true, f.ipc, f.relay), + "stopped", + ); + assert.ok( + f.publishes.filter((e) => e.kind === 50180).every((e) => e === request), + ); + assert.ok( + f.publishes.filter((e) => e.kind === 50181).every((e) => e === result), + ); + close(); + assert.equal(f.closed(), true); +}); + +test("duplicate delivery during native Stop/result publication is coalesced", async () => { + const f = fixture(); + let release; + const wait = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const ipc = async (...args) => { + calls++; + await wait; + return f.ipc(...args); + }; + const close = await receiveStops( + scope, + () => true, + () => {}, + ipc, + f.relay, + ); + f.deliver(); + f.deliver(); + assert.equal(calls, 1); + release(); + await tick(); + assert.equal(f.effect(), 1); + close(); +}); + +test("scope change after native effect prevents result publication", async () => { + const f = fixture(); + const ipc = async (...args) => { + const value = await f.ipc(...args); + f.switchScope(); + return value; + }; + const close = await receiveStops( + scope, + () => true, + () => {}, + ipc, + f.relay, + ); + f.deliver(); + await tick(); + assert.equal(f.effect(), 1, "dispatched Stop may finish"); + assert.equal( + f.publishes.length, + 0, + "late result cannot cross the scope boundary", + ); + close(); +}); + +test("publish rate-limit/reconnect wait rechecks mounted owner scope before send", async () => { + const f = fixture(); + let active = true; + f.relay.publishEvent = async (_event, _timeout, _failure, check) => { + active = false; + check(); + }; + await assert.rejects( + sendStop(scope, request, () => active, f.relay), + /scope changed/, + ); + const ipc = async (...args) => { + const value = await f.ipc(...args); + f.switchScope(); + return value; + }; + await assert.rejects( + prepareStop(scope, "desktop", "agent", () => true, ipc, f.relay), + /scope changed/, + ); +}); diff --git a/desktop/src/features/agents/desktopStop.ts b/desktop/src/features/agents/desktopStop.ts new file mode 100644 index 00000000000..3385813c691 --- /dev/null +++ b/desktop/src/features/agents/desktopStop.ts @@ -0,0 +1,146 @@ +import { invoke } from "@tauri-apps/api/core"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; + +export const DESKTOP_STOP = 50180; +export const DESKTOP_STOP_RESULT = 50181; +export type StopOutcome = "stopped" | "failed" | "unknown"; + +function guard( + scope: DesktopScope, + active: () => boolean, + relay: typeof relayClient, +) { + const epoch = relay.getSessionEpoch(); + return () => { + if (!active() || relay.getSessionEpoch() !== epoch) + throw new Error(`Desktop Stop scope changed (${scope.community})`); + }; +} + +/** A mounted operation retains the exact signed request for explicit retry. */ +export async function prepareStop( + scope: DesktopScope, + desktop: string, + agent: string, + active: () => boolean, + ipc = invoke, + relay = relayClient, +): Promise { + const check = guard(scope, active, relay); + check(); + const request = await ipc("prepare_desktop_stop", { + ...scope, + desktop, + agent, + }); + check(); + return request; +} + +/** ACK is delivery only; a missing authenticated correlated result is Unknown. */ +export async function sendStop( + scope: DesktopScope, + request: RelayEvent, + active: () => boolean, + relay = relayClient, +): Promise { + const check = guard(scope, active, relay); + check(); + await relay.publishEvent( + request, + "Stop delivery unconfirmed", + "Stop delivery failed", + check, + ); + check(); +} + +export async function readStopOutcome( + scope: DesktopScope, + request: RelayEvent, + active: () => boolean, + ipc = invoke, + relay = relayClient, +): Promise { + const check = guard(scope, active, relay); + check(); + const events = await relay.fetchEvents({ + kinds: [DESKTOP_STOP_RESULT], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + check(); + const outcome = await ipc("read_desktop_stop_results", { + ...scope, + request, + events, + }); + check(); + return outcome; +} + +/** Live only: never fetch or replay historical commands when Desktop reopens. */ +export async function receiveStops( + scope: DesktopScope, + active: () => boolean, + onError: (message: string) => void, + ipc = invoke, + relay = relayClient, +) { + const check = guard(scope, active, relay); + const pending = new Set(); + check(); + const unsubscribe = await relay.subscribeLive( + { kinds: [DESKTOP_STOP], authors: [scope.owner], limit: 0 }, + (event) => { + if (!active()) return; + if (pending.has(event.id)) return; + if (pending.size >= 16) { + onError( + "Remote Stop receiver is busy. Unconfirmed requests can be retried.", + ); + return; + } + pending.add(event.id); + void (async () => { + check(); + const result = await ipc("receive_desktop_stop", { + ...scope, + event, + }); + check(); + if (result) + await relay.publishEvent( + result, + "Stop result delivery unconfirmed", + "Stop result delivery failed", + check, + ); + check(); + })() + .catch(() => { + if (active()) + onError( + "A remote Stop result could not be confirmed. Retry the same Stop to request its saved outcome.", + ); + }) + .finally(() => { + pending.delete(event.id); + }); + }, + (readiness) => { + if (active() && readiness !== "eose") + onError("Remote Stop receiver is unavailable."); + }, + ); + try { + check(); + } catch (error) { + unsubscribe(); + throw error; + } + return unsubscribe; +} From 20436c81f21c65676686d9350faeed7df4d4b609 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 16:00:35 -0400 Subject: [PATCH 12/51] feat(desktop): mount private remote Stop controls and live receiver Signed-off-by: Logan Johnson --- desktop/src/features/agents/AGENTS.md | 7 +- .../agents/ui/DesktopStopControl.test.mjs | 155 ++++++++++++++++ .../features/agents/ui/DesktopStopControl.tsx | 170 ++++++++++++++++++ .../src/features/agents/ui/KnownDesktops.tsx | 13 +- 4 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/agents/ui/DesktopStopControl.test.mjs create mode 100644 desktop/src/features/agents/ui/DesktopStopControl.tsx diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index c196e8653c0..be408c39b36 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -310,9 +310,10 @@ with a TypeScript lookup table or an id comparison in a component. ## Remote Desktop Stop -Native IPC accepts an owner-private, explicitly selected agent+Desktop Stop, -not inferred agent location. The relay redelivers stored Stop duplicates without -repeating relay side effects. +Known Desktops exposes an owner-private, explicitly selected agent+Desktop Stop, +not inferred agent location. The app-scoped receiver subscribes live only; +reopening never replays commands. An explicit retry republishes the exact request; +the relay redelivers stored Stop duplicates without repeating relay side effects. The receiver returns saved results or Unknown, never repeats a consumed Stop. Native owner-delegation and community checks precede durable admission and ordinary pair Stop. A delivery ACK is not success. diff --git a/desktop/src/features/agents/ui/DesktopStopControl.test.mjs b/desktop/src/features/agents/ui/DesktopStopControl.test.mjs new file mode 100644 index 00000000000..d7d8b1dfc1f --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopStopControl.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + DesktopStopControl, + DesktopStopReceiver, +} from "./DesktopStopControl.tsx"; +import { relayClient } from "../../../shared/api/relayClient.ts"; + +test("mounted Stop waits for a correlated result and retries identical bytes without replay", async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const scope = { owner: "owner", community: "wss://one.example" }; + const request = { id: "request", kind: 50180, tags: [["d", "desktop"]] }; + const result = { id: "result", kind: 50181, tags: [["e", request.id]] }; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + ["relay-agents"], + [ + { pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" }, + { pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" }, + ], + ); + const original = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + subscribe: relayClient.subscribeLive, + }; + let live, release; + let receiveCalls = 0, + prepared = 0, + closed = 0; + const sent = []; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + if (command === "prepare_desktop_stop") { + prepared++; + assert.equal(args.desktop, "desktop"); + assert.equal(args.agent, "agent"); + return request; + } + if (command === "receive_desktop_stop") { + receiveCalls++; + return result; + } + assert.equal(command, "read_desktop_stop_results"); + return "stopped"; + }, + }; + relayClient.subscribeLive = async (filter, callback) => { + assert.deepEqual(filter, { + kinds: [50180], + authors: [scope.owner], + limit: 0, + }); + live = callback; + return () => { + closed++; + live = undefined; + }; + }; + relayClient.publishEvent = async (event) => { + sent.push(event); + live?.(event); + }; + relayClient.fetchEvents = async (filter) => { + assert.deepEqual(filter, { + kinds: [50181], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + return new Promise((resolve) => { + release = () => resolve([result]); + }); + }; + const root = createRoot(document.getElementById("root")); + const render = (receiver) => + React.act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + React.Fragment, + null, + receiver + ? React.createElement(DesktopStopReceiver, { scope }) + : null, + React.createElement(DesktopStopControl, { + scope, + desktop: { id: "desktop", name: "Workshop" }, + }), + ), + ), + ), + ); + const click = (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + try { + await render(false); + assert.doesNotMatch(document.body.textContent, /Foreign agent/); + const select = document.querySelector("select"); + await React.act(async () => { + select.value = "agent"; + select.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + await click("Stop on Workshop"); + assert.equal(prepared, 1); + assert.match(document.body.textContent, /Waiting for this Desktop/); + assert.doesNotMatch(document.body.textContent, /Stop confirmed/); + assert.equal(receiveCalls, 0, "absent receiver has not stopped anything"); + await React.act(async () => release()); + assert.match(document.body.textContent, /Stop confirmed by Workshop/); + await render(true); + assert.equal(receiveCalls, 0, "mount cannot replay stored Stop"); + // The mounted receiver returns a saved native result, while the sender + // explicitly retries the exact prepared request rather than signing anew. + relayClient.publishEvent = async (event) => { + sent.push(event); + if (event.kind === 50180) live?.(event); + }; + await click("Retry same Stop"); + await React.act(async () => release()); + assert.equal(prepared, 1); + assert.equal(receiveCalls, 1); + assert.ok(sent.filter((e) => e.kind === 50180).every((e) => e === request)); + } finally { + await React.act(async () => root.unmount()); + assert.equal(closed, 1); + client.clear(); + relayClient.fetchEvents = original.fetch; + relayClient.publishEvent = original.publish; + relayClient.subscribeLive = original.subscribe; + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/ui/DesktopStopControl.tsx b/desktop/src/features/agents/ui/DesktopStopControl.tsx new file mode 100644 index 00000000000..bd5e8f56cd7 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopStopControl.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from "react"; +import { useRelayAgentsQuery } from "../hooks"; +import { + prepareStop, + readStopOutcome, + receiveStops, + sendStop, +} from "../desktopStop"; +import type { DesktopScope, DesktopRow } from "../desktopList"; +import type { RelayEvent } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; + +/** App-scoped live receiver; no historical requests are loaded on mount. */ +export function DesktopStopReceiver({ scope }: { scope: DesktopScope | null }) { + const [error, setError] = useState(""); + const { owner, community } = scope ?? {}; + useEffect(() => { + if (!owner || !community) return; + let active = true; + let close: (() => void) | undefined; + setError(""); + void receiveStops({ owner, community }, () => active, setError) + .then((unsubscribe) => { + if (active) close = unsubscribe; + else unsubscribe(); + }) + .catch(() => { + if (active) + setError("Remote Stop receiver is unavailable on this Desktop."); + }); + return () => { + active = false; + close?.(); + }; + }, [owner, community]); + return error ? ( +

+ {error} +

+ ) : null; +} + +/** Deliberately selects a host, not an inferred running location or presence. */ +export function DesktopStopControl({ + scope, + desktop, +}: { + scope: DesktopScope; + desktop: DesktopRow; +}) { + const agents = useRelayAgentsQuery(); + const owned = (agents.data ?? []).filter( + (agent) => agent.ownerPubkey === scope.owner, + ); + const [agent, setAgent] = useState(""); + const [request, setRequest] = useState(null); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + const active = useRef(true); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + }; + }, []); + const run = async (retry: boolean) => { + setBusy(true); + let current = retry ? request : null; + try { + current ??= await prepareStop( + scope, + desktop.id, + agent, + () => active.current, + ); + if (!active.current) return; + setRequest(current); + setMessage("Stop requested. Waiting for this Desktop’s result…"); + try { + await sendStop(scope, current, () => active.current); + } catch { + if (active.current) + setMessage( + "Delivery unconfirmed. Checking for this Desktop’s result…", + ); + } + for (let attempt = 0; attempt < 15 && active.current; attempt++) { + const outcome = await readStopOutcome( + scope, + current, + () => active.current, + ); + if (!active.current) return; + if (outcome === "stopped") { + setMessage(`Stop confirmed by ${desktop.name}.`); + return; + } + if (outcome === "failed") { + setMessage( + `Stop failed on ${desktop.name}. No success was confirmed.`, + ); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (active.current) + setMessage( + "Stop unconfirmed. This Desktop may be unavailable; its agents may still be running.", + ); + } catch { + if (active.current) + setMessage("Stop unconfirmed. No successful result could be read."); + } finally { + if (active.current) setBusy(false); + } + }; + return ( +
+ +

+ Stops only this agent on this Desktop in this community. This list does + not establish where it is running. +

+ {agents.isError &&

Your agent list is unavailable.

} + + {request && ( + + )} + {message && ( +

+ {message} +

+ )} +
+ ); +} diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx index 16d872c2d8b..45e7aa68eaa 100644 --- a/desktop/src/features/agents/ui/KnownDesktops.tsx +++ b/desktop/src/features/agents/ui/KnownDesktops.tsx @@ -1,3 +1,4 @@ +import { DesktopStopControl, DesktopStopReceiver } from "./DesktopStopControl"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -19,6 +20,7 @@ import { import { DesktopCapabilityDetails } from "./DesktopCapabilityDetails"; type View = { + scope?: import("../desktopList").DesktopScope; capabilities?: DesktopCapabilities[]; capabilityWarning?: string; list: DesktopList | null; @@ -78,7 +80,7 @@ export function DesktopListStartup() { unsubscribe(); }; }, [refetch, pulse, report]); - return null; + return ; } export function KnownDesktops() { @@ -92,6 +94,7 @@ export function KnownDesktops() { }, []); return ( + {scope && ( + + )} item.id === row.id)} now={now} From c6be5382f1cae0ffee4ab30f0cbeb64e35aed51a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 16:27:36 -0400 Subject: [PATCH 13/51] test(multiverse): exercise mounted Stop recovery and private fanout in CI Signed-off-by: Logan Johnson --- Justfile | 4 +- desktop/playwright.config.ts | 1 + desktop/src/testing/e2eBridge.ts | 7 ++ desktop/tests/e2e/desktop-stop.spec.ts | 163 +++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 desktop/tests/e2e/desktop-stop.spec.ts diff --git a/Justfile b/Justfile index e44d3158777..3a18d0b321e 100644 --- a/Justfile +++ b/Justfile @@ -445,8 +445,10 @@ test-unit: # non-postgres_tests cases only "pass" without a database by waiting out # the ~30s sqlx acquire timeout, so they do not belong in the infra-free # unit job either. + # The author-only fanout family uses lazy pools and in-memory recipients; + # include Stop request/result privacy and its sibling private kinds. cargo nextest run -p buzz-relay --lib \ - -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/)' + -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::event::tests::fanout_access::.*_delivers_to_author_only$/)' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index aad2580dad0..3ce7eb3b81f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/desktop-stop.spec.ts", "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6ddc1111b03..89b1fb20aed 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11273,6 +11273,13 @@ function sendToMockSocket(args: { return; } + // Desktop inventory/control records are global-only. Native IPC owns their + // encryption and result validation; smoke fixtures supply that boundary. + if ([30180, 30181, 30182, 50180, 50181].includes(event.kind)) { + sendWsText(socket.handler, ["OK", event.id, true, ""]); + return; + } + const channelId = getChannelIdFromTags(event.tags); if (!channelId) { sendWsText(socket.handler, [ diff --git a/desktop/tests/e2e/desktop-stop.spec.ts b/desktop/tests/e2e/desktop-stop.spec.ts new file mode 100644 index 00000000000..a656aa45623 --- /dev/null +++ b/desktop/tests/e2e/desktop-stop.spec.ts @@ -0,0 +1,163 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +// These are IPC fixtures, not native execution evidence. The real mounted +// Known Desktops, client, relay publisher and retry control remain in the path. +test("remote Stop distinguishes delivery, uncertainty, and confirmed result", async ({ + page, +}) => { + test.setTimeout(60_000); + const agent = "a7".repeat(32); + await installMockBridge(page, { + managedAgents: [], + relayAgents: [ + { + pubkey: agent, + name: "Scout", + ownerPubkey: "deadbeef".repeat(8), + status: "unknown", + respondTo: "owner-only", + channelNames: [], + channelIds: [], + }, + ], + }); + await page.goto("/"); + await expect(page.getByTestId("open-agents-view")).toBeVisible(); + await page.evaluate(() => { + const w = window as typeof window & { + __STOP_FIXTURE__: { + confirmed: boolean; + prepared: number; + sends: string[]; + }; + __TAURI_INTERNALS__: { + invoke: (command: string, payload?: any, options?: any) => Promise; + }; + }; + w.__STOP_FIXTURE__ = { confirmed: false, prepared: 0, sends: [] }; + const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); + const now = Math.floor(Date.now() / 1000); + const local = "11111111-1111-4111-8111-111111111111"; + const remote = "22222222-2222-4222-8222-222222222222"; + const sign = async (kind: number, tags: string[][] = []) => + JSON.parse( + await original("sign_event", { + kind, + tags, + content: "encrypted IPC fixture", + createdAt: now, + }), + ); + w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => { + switch (command) { + case "prepare_desktop_profile": + return { event: await sign(30180, [["d", local]]) }; + case "read_desktop_profiles": + return [ + { id: local, name: "Laptop", updated: now }, + { id: remote, name: "Lab Desktop", updated: now }, + ]; + case "prepare_desktop_observation": + return { event: await sign(30181, [["d", local]]) }; + case "read_desktop_observations": + return [ + { id: local, heard: now }, + { id: remote, heard: now - 600 }, + ]; + case "prepare_desktop_capabilities": + return { event: await sign(30182, [["d", local]]) }; + case "read_desktop_capabilities": + return [local, remote].map((id) => ({ + id, + reported: now, + runtimes: [], + })); + case "prepare_desktop_stop": + w.__STOP_FIXTURE__.prepared++; + return sign(50180, [ + ["p", payload.owner], + ["d", payload.desktop], + ]); + case "receive_desktop_stop": + return null; + case "read_desktop_stop_results": + return w.__STOP_FIXTURE__.confirmed ? "stopped" : "unknown"; + case "plugin:websocket|send": { + const wire = JSON.parse(payload.message.data); + if (wire[0] === "EVENT" && wire[1]?.kind === 50180) + w.__STOP_FIXTURE__.sends.push(JSON.stringify(wire[1])); + break; + } + } + return original(command, payload, options); + }; + }); + await page.getByTestId("open-agents-view").click(); + const desktops = page.getByRole("region", { name: "Known Desktops" }); + await desktops.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect( + desktops.getByText("Lab Desktop", { exact: true }), + ).toBeVisible(); + await desktops + .getByRole("combobox", { name: "Agent to stop on Lab Desktop" }) + .selectOption(agent); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/01-selected.png", + }); + + await desktops + .getByRole("button", { name: "Stop on Lab Desktop", exact: true }) + .click(); + await expect( + desktops.getByText("Stop requested. Waiting for this Desktop’s result…", { + exact: true, + }), + ).toBeVisible(); + await expect( + desktops.getByText("Stop confirmed by Lab Desktop.", { exact: true }), + ).toHaveCount(0); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/02-waiting.png", + }); + + await expect( + desktops.getByText( + "Stop unconfirmed. This Desktop may be unavailable; its agents may still be running.", + { exact: true }, + ), + ).toBeVisible({ timeout: 25_000 }); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/03-unconfirmed.png", + }); + await page.evaluate(() => { + ( + window as typeof window & { __STOP_FIXTURE__: { confirmed: boolean } } + ).__STOP_FIXTURE__.confirmed = true; + }); + await desktops + .getByRole("button", { name: "Retry same Stop", exact: true }) + .click(); + await expect( + desktops.getByText("Stop confirmed by Lab Desktop.", { exact: true }), + ).toBeVisible(); + const result = await page.evaluate( + () => + ( + window as typeof window & { + __STOP_FIXTURE__: { prepared: number; sends: string[] }; + } + ).__STOP_FIXTURE__, + ); + expect(result.prepared).toBe(1); + expect(result.sends).toHaveLength(2); + expect(result.sends[1]).toBe(result.sends[0]); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/04-confirmed.png", + }); +}); From 83a2df13cf292500450b804cd788b67fd730ad5e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 17:46:28 -0400 Subject: [PATCH 14/51] feat(multiverse): add private immutable lifecycle transport Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_lifecycle.rs | 195 ++++++++++++++++++ crates/buzz-core/src/desktop_stop.rs | 17 +- crates/buzz-core/src/kind.rs | 8 + crates/buzz-core/src/lib.rs | 1 + crates/buzz-db/src/runtime/migration.rs | 29 ++- .../src/api/desktop_profile_postgres_tests.rs | 58 +++++- crates/buzz-relay/src/handlers/ingest.rs | 21 +- migrations/0049_desktop_lifecycle_fts.sql | 26 +++ schema/schema.sql | 2 +- 9 files changed, 337 insertions(+), 20 deletions(-) create mode 100644 crates/buzz-core/src/desktop_lifecycle.rs create mode 100644 migrations/0049_desktop_lifecycle_fts.sql diff --git a/crates/buzz-core/src/desktop_lifecycle.rs b/crates/buzz-core/src/desktop_lifecycle.rs new file mode 100644 index 00000000000..c437830f4aa --- /dev/null +++ b/crates/buzz-core/src/desktop_lifecycle.rs @@ -0,0 +1,195 @@ +//! Owner-private lifecycle requests. Signed order is intent, not process state. +use crate::{ + desktop_stop::{hex, read, sign, StopTarget}, + kind::{KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT}, +}; +use nostr::{Event, Keys, Tag}; +use serde::{Deserialize, Serialize}; + +/// Start chooses a destination. Restart is a current-host-only one-shot. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Action { + /// Explicit ensure-running, without a remote reachability gate. + Start, + /// Ordinary Stop then one fresh launch, only on the resolved current host. + Restart, + /// Read actual local process status; never starts or stops anything. + Status, +} + +/// Immutable request; retries retain its exact signed bytes. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Request { + /// Existing owner/community/agent/Desktop target shape. + pub target: StopTarget, + /// Requested operation, never shell text or configuration. + pub action: Action, + /// Restart's fresh successful Status request ID. None for other actions. + pub observed: Option, +} + +/// No credentials, paths, PIDs or raw runtime errors on the wire. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + /// Ordinary process registration/actual status confirms running locally. + Running, + /// Actual status confirms no managed process at this target. + Stopped, + /// Destination-local broker session issuance is not available. + ProvisioningUnavailable, + /// Runtime/readiness/ownership rejected the request. + Failed, + /// Superseded, interrupted, evicted or uncertain; never success. + Unknown, +} + +/// Signed Desktop outcome, not agent-signed termination proof. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ResultMessage { + /// Original immutable payload. + pub request: Request, + /// Original signed event identity. + pub id: String, + /// Local Desktop result. + pub outcome: Outcome, +} + +/// Public envelope gate before persistence; content remains owner encrypted. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + let kind = event.kind.as_u16() as u32; + let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect(); + let result = kind == KIND_DESKTOP_LIFECYCLE_RESULT; + if !matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT) + || !(132..=4096).contains(&event.content.len()) + || tags.len() != if result { 2 } else { 1 } + || tags[0].len() != 2 + || tags[0][0] != "d" + || !hex(&tags[0][1], 32) + || (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64))) + { + return Err("invalid Desktop lifecycle envelope"); + } + Ok(()) +} + +impl Request { + /// Validate target, action and correlation without inventing credentials. + pub fn validate(&self, community: &str) -> Result<(), String> { + self.target.validate(community)?; + match (self.action, &self.observed) { + (Action::Restart, Some(id)) if hex(id, 64) => Ok(()), + (Action::Start | Action::Status, None) => Ok(()), + _ => Err("invalid Desktop lifecycle observation".into()), + } + } + /// Prepare once; retries must not create a new event/order. + pub fn sign(&self, keys: &Keys) -> Result { + self.validate(&self.target.community)?; + sign( + self, + keys, + KIND_DESKTOP_LIFECYCLE, + vec![Tag::identifier(&self.target.desktop)], + ) + } + /// Authenticate owner, content, routing and captured community. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE)?; + value.validate(community)?; + if event.tags.identifier() != Some(value.target.desktop.as_str()) { + return Err("Desktop lifecycle routing mismatch".into()); + } + Ok(value) + } +} +impl ResultMessage { + /// Sign the actual Desktop result. It is immutable for this request. + pub fn sign(&self, keys: &Keys) -> Result { + self.request.validate(&self.request.target.community)?; + if !hex(&self.id, 64) { + return Err("invalid lifecycle request ID".into()); + } + sign( + self, + keys, + KIND_DESKTOP_LIFECYCLE_RESULT, + vec![ + Tag::identifier(&self.request.target.desktop), + Tag::parse(["e", &self.id]).map_err(|e| e.to_string())?, + ], + ) + } + /// Bind every correlation field to the original authenticated request. + pub fn read( + event: &Event, + keys: &Keys, + request: &Event, + community: &str, + ) -> Result { + let original = Request::read(request, keys, community)?; + let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE_RESULT)?; + if value.request != original + || value.id != request.id.to_hex() + || event.tags.identifier() != Some(original.target.desktop.as_str()) + || event.tags.iter().nth(1).and_then(|t| t.content()) != Some(value.id.as_str()) + { + return Err("Desktop lifecycle result mismatch".into()); + } + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn scope_action_and_result_are_bound_to_one_signed_request() { + let keys = Keys::generate(); + let mut request = Request { + target: StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }, + action: Action::Start, + observed: None, + }; + let event = request.sign(&keys).unwrap(); + assert_eq!( + Request::read(&event, &keys, &request.target.community).unwrap(), + request + ); + assert!(Request::read(&event, &Keys::generate(), &request.target.community).is_err()); + assert!(Request::read(&event, &keys, "wss://other.example").is_err()); + assert!( + crate::desktop_stop::StopTarget::read(&event, &keys, &request.target.community) + .is_err() + ); + let result = ResultMessage { + request: request.clone(), + id: event.id.to_hex(), + outcome: Outcome::ProvisioningUnavailable, + } + .sign(&keys) + .unwrap(); + assert_eq!( + ResultMessage::read(&result, &keys, &event, &request.target.community) + .unwrap() + .outcome, + Outcome::ProvisioningUnavailable + ); + let other = request.sign(&keys).unwrap(); + assert!(ResultMessage::read(&result, &keys, &other, &request.target.community).is_err()); + request.action = Action::Restart; + assert!(request.sign(&keys).is_err()); + request.observed = Some(event.id.to_hex()); + assert!(request.sign(&keys).is_ok()); + request.action = Action::Start; + assert!(request.sign(&keys).is_err()); + } +} diff --git a/crates/buzz-core/src/desktop_stop.rs b/crates/buzz-core/src/desktop_stop.rs index 4f431655e99..9291c323c8b 100644 --- a/crates/buzz-core/src/desktop_stop.rs +++ b/crates/buzz-core/src/desktop_stop.rs @@ -42,7 +42,7 @@ pub struct StopResult { pub outcome: StopOutcome, } -fn hex(value: &str, len: usize) -> bool { +pub(crate) fn hex(value: &str, len: usize) -> bool { value.len() == len && value .bytes() @@ -67,7 +67,12 @@ pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { Ok(()) } -fn sign(value: &T, keys: &Keys, kind: u32, tags: Vec) -> Result { +pub(crate) fn sign( + value: &T, + keys: &Keys, + kind: u32, + tags: Vec, +) -> Result { let ciphertext = nip44::encrypt( keys.secret_key(), &keys.public_key(), @@ -81,12 +86,16 @@ fn sign(value: &T, keys: &Keys, kind: u32, tags: Vec) -> Resu .map_err(|e| e.to_string()) } -fn read( +pub(crate) fn read( event: &Event, keys: &Keys, kind: u32, ) -> Result { - validate_envelope(event)?; + if matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { + validate_envelope(event)?; + } else { + crate::desktop_lifecycle::validate_envelope(event)?; + } event .verify() .map_err(|_| "invalid Desktop Stop signature")?; diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 5df974c36cb..3146c511a28 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -129,6 +129,10 @@ pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182; pub const KIND_DESKTOP_STOP: u32 = 50180; /// Owner-private ordinary Desktop Stop outcome, correlated by request event ID. pub const KIND_DESKTOP_STOP_RESULT: u32 = 50181; +/// Owner-private Start/Restart/status request, separate from legacy Stop. +pub const KIND_DESKTOP_LIFECYCLE: u32 = 50182; +/// Correlated owner-private Desktop lifecycle result. +pub const KIND_DESKTOP_LIFECYCLE_RESULT: u32 = 50183; /// Kinds whose stored events are readable only by their author. /// @@ -148,6 +152,8 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_LIFECYCLE, + KIND_DESKTOP_LIFECYCLE_RESULT, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -682,6 +688,8 @@ pub const ALL_KINDS: &[u32] = &[ KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_LIFECYCLE, + KIND_DESKTOP_LIFECYCLE_RESULT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 4b3874be0f0..d80446ac35e 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; pub mod desktop_capabilities; +pub mod desktop_lifecycle; pub mod desktop_observation; /// Owner-private Desktop display profiles. pub mod desktop_profile; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 35261123b61..8d2bf6ea697 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 48); + assert_eq!(migrations.len(), 49); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -911,7 +911,7 @@ mod postgres_tests { 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, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181)" + "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183)" )); // Public push-gateway authority is intentionally deployment-global and @@ -2396,6 +2396,8 @@ mod postgres_tests { (6_u8, 30_182_i32), (7_u8, 50_180_i32), (8_u8, 50_181_i32), + (9_u8, 50_182_i32), + (10_u8, 50_183_i32), ] { sqlx::query( "INSERT INTO events \ @@ -2433,7 +2435,9 @@ mod postgres_tests { (30_182, true), (30_350, true), (50_180, true), - (50_181, true) + (50_181, true), + (50_182, true), + (50_183, true) ] ); @@ -2460,7 +2464,9 @@ mod postgres_tests { (30_182, Some(true)), (30_350, None), (50_180, Some(true)), - (50_181, Some(true)) + (50_181, Some(true)), + (50_182, Some(true)), + (50_183, Some(true)) ] ); @@ -2507,6 +2513,17 @@ mod postgres_tests { .await .unwrap(); assert_eq!(stop_indexed, 2, "0048 must change brownfield Stop FTS"); + run_migrations_through(&pool, 48).await.unwrap(); + let lifecycle_indexed: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE kind IN (50182, 50183) AND search_tsv IS NOT NULL", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + lifecycle_indexed, 2, + "0049 must change populated lifecycle FTS" + ); run_migrations(&pool) .await @@ -2528,7 +2545,9 @@ mod postgres_tests { (30_182, None), (30_350, None), (50_180, None), - (50_181, None) + (50_181, None), + (50_182, None), + (50_183, None) ] ); let gin_exists: bool = sqlx::query_scalar( diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs index ac35a0dd12e..29b21068d37 100644 --- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -3,8 +3,8 @@ use super::postgres_tests::bridge_handler_test_state; use super::*; use axum::{body::Body, http::Request}; use buzz_core::kind::{ - KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, - KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT, + KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, }; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; @@ -92,6 +92,13 @@ async fn desktop_stop_authenticated_owner_query_and_private_storage() { assert_private_desktop(KIND_DESKTOP_STOP_RESULT).await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_lifecycle_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_LIFECYCLE).await; + assert_private_desktop(KIND_DESKTOP_LIFECYCLE_RESULT).await; +} + async fn assert_private_desktop(kind: u32) { let mut state = bridge_handler_test_state() .await @@ -132,6 +139,29 @@ async fn assert_private_desktop(kind: u32) { .sign(&owner) .unwrap() } + } else if matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT) { + let request = buzz_core::desktop_lifecycle::Request { + target: buzz_core::desktop_stop::StopTarget { + v: 1, + community: format!("wss://{host}"), + desktop: id.clone(), + agent: Keys::generate().public_key().to_hex(), + }, + action: buzz_core::desktop_lifecycle::Action::Start, + observed: None, + }; + let event = request.sign(&owner).unwrap(); + if kind == KIND_DESKTOP_LIFECYCLE { + event + } else { + buzz_core::desktop_lifecycle::ResultMessage { + request, + id: event.id.to_hex(), + outcome: buzz_core::desktop_lifecycle::Outcome::Running, + } + .sign(&owner) + .unwrap() + } } else if kind == KIND_DESKTOP_PROFILE { profile.sign(&owner).unwrap() } else if kind == KIND_DESKTOP_CAPABILITIES { @@ -320,6 +350,14 @@ async fn aged_desktop_profile_retries_through_production_ingest_without_resignin #[tokio::test] #[ignore = "requires Postgres and Redis"] async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { + assert_retry(KIND_DESKTOP_STOP).await; +} +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn desktop_lifecycle_retry_redelivers_exact_event_only_to_owner() { + assert_retry(KIND_DESKTOP_LIFECYCLE).await; +} +async fn assert_retry(kind: u32) { use nostr::Filter; use std::sync::atomic::AtomicU8; use tokio::sync::{mpsc, Mutex}; @@ -343,7 +381,17 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { desktop: uuid::Uuid::new_v4().simple().to_string(), agent: Keys::generate().public_key().to_hex(), }; - let prepared = target.sign(&owner).unwrap(); + let prepared = if kind == KIND_DESKTOP_STOP { + target.sign(&owner).unwrap() + } else { + buzz_core::desktop_lifecycle::Request { + target, + action: buzz_core::desktop_lifecycle::Action::Start, + observed: None, + } + .sign(&owner) + .unwrap() + }; let event = EventBuilder::new(prepared.kind, &prepared.content) .tags(prepared.tags.iter().cloned()) .custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 86_400)) @@ -383,7 +431,7 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { tenant, conn, "stop".into(), - vec![Filter::new().kind(Kind::Custom(KIND_DESKTOP_STOP as u16))], + vec![Filter::new().kind(Kind::Custom(kind as u16))], None, ); receivers.push(rx); @@ -407,6 +455,6 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); assert!(drain(&mut receivers[0]).is_empty()); let (_, rows) = post(&state, &host, "/query", &owner, - json!([{"kinds":[KIND_DESKTOP_STOP],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await; + json!([{"kinds":[kind],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await; assert_eq!(rows.as_array().unwrap().len(), 1); } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 112bea02085..88d2d43ed4e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -37,8 +37,8 @@ use buzz_core::kind::{ RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::kind::{ - KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, - KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT, + KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -440,7 +440,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 | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT => Ok(Scope::UsersWrite), + KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT | KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT => 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 @@ -665,7 +665,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP - | KIND_DESKTOP_STOP_RESULT + | KIND_DESKTOP_STOP_RESULT | KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -2189,6 +2189,8 @@ fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool { | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT + | KIND_DESKTOP_LIFECYCLE + | KIND_DESKTOP_LIFECYCLE_RESULT ) || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) } @@ -2804,6 +2806,13 @@ async fn ingest_event_inner( } } + if matches!( + kind_u32, + KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT + ) { + buzz_core::desktop_lifecycle::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { buzz_core::desktop_stop::validate_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3250,7 +3259,7 @@ async fn ingest_event_inner( // Stop is a one-shot owned by Desktop, not a replaceable projection. // Explicit transport retry must reach a live receiver even after an ACK // or its result was lost. Never replay history or repeat relay effects. - if kind_u32 == KIND_DESKTOP_STOP { + if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_LIFECYCLE) { super::event::redeliver_desktop_stop(tenant, state, &stored_event.event).await; } return Ok(IngestResult { @@ -3391,6 +3400,8 @@ mod postgres_tests { | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT + | KIND_DESKTOP_LIFECYCLE + | KIND_DESKTOP_LIFECYCLE_RESULT ) { profile } else { diff --git a/migrations/0049_desktop_lifecycle_fts.sql b/migrations/0049_desktop_lifecycle_fts.sql new file mode 100644 index 00000000000..ad42a795a2e --- /dev/null +++ b/migrations/0049_desktop_lifecycle_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop lifecycle requests and results must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +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 (50182, 50183) 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 0a20e940285..03606b1645f 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From b85c53293ff4d84085668050539a99c1d99537d4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 17:46:29 -0400 Subject: [PATCH 15/51] feat(desktop): persist ordered placement and lifecycle admission Signed-off-by: Logan Johnson --- .../src-tauri/src/managed_agents/placement.rs | 217 +++++++++++++ .../src/managed_agents/placement/tests.rs | 284 ++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 desktop/src-tauri/src/managed_agents/placement.rs create mode 100644 desktop/src-tauri/src/managed_agents/placement/tests.rs diff --git a/desktop/src-tauri/src/managed_agents/placement.rs b/desktop/src-tauri/src/managed_agents/placement.rs new file mode 100644 index 00000000000..13efe7a8b19 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/placement.rs @@ -0,0 +1,217 @@ +//! Compact intent, separate from one-shot execution. No history replay. +use buzz_core_pkg::{ + desktop_lifecycle::{Action, Outcome, Request, ResultMessage}, + desktop_stop::StopTarget, + kind::KIND_DESKTOP_STOP, +}; +use nostr::{Event, JsonUtil, Keys}; +use rusqlite::{params, Connection, OptionalExtension}; + +pub(crate) fn schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS desktop_placement ( + agent TEXT NOT NULL, slot TEXT NOT NULL, host TEXT NOT NULL, stamp INTEGER NOT NULL, + id TEXT NOT NULL, PRIMARY KEY(agent,slot)); + CREATE TABLE IF NOT EXISTS desktop_lifecycle_admission ( + agent TEXT NOT NULL, host TEXT NOT NULL, action TEXT NOT NULL, stamp INTEGER NOT NULL, + id TEXT NOT NULL, PRIMARY KEY(agent,host,action)); + CREATE TABLE IF NOT EXISTS desktop_lifecycle_results ( + id TEXT PRIMARY KEY, raw TEXT NOT NULL);", + ) + .map_err(|e| e.to_string()) +} + +/// Start's shared max and each host's Stop max are sufficient statistics. +/// Stopping newest Start never falls back to an earlier host. +pub(crate) fn observe( + conn: &Connection, + event: &Event, + keys: &Keys, + community: &str, +) -> Result { + let (target, slot) = if event.kind.as_u16() as u32 == KIND_DESKTOP_STOP { + let target = StopTarget::read(event, keys, community)?; + let slot = format!("stop:{}", target.desktop); + (target, slot) + } else { + let request = Request::read(event, keys, community)?; + if request.action != Action::Start { + return Ok(request.target.agent); + } + (request.target, "start".into()) + }; + schema(conn)?; + conn.execute( + "INSERT INTO desktop_placement VALUES (?1,?2,?3,?4,?5) + ON CONFLICT(agent,slot) DO UPDATE SET host=excluded.host,stamp=excluded.stamp,id=excluded.id + WHERE excluded.stamp > desktop_placement.stamp OR + (excluded.stamp = desktop_placement.stamp AND excluded.id < desktop_placement.id)", + params![ + target.agent, + slot, + target.desktop, + event.created_at.as_secs(), + event.id.to_hex() + ], + ) + .map_err(|e| e.to_string())?; + Ok(target.agent) +} + +type Row = (String, u64, String); +fn row(conn: &Connection, agent: &str, slot: &str) -> Result, String> { + schema(conn)?; + conn.query_row( + "SELECT host,stamp,id FROM desktop_placement WHERE agent=?1 AND slot=?2", + params![agent, slot], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional() + .map_err(|e| e.to_string()) +} +fn newer(a: &Row, b: &Row) -> bool { + a.1 > b.1 || (a.1 == b.1 && a.2 < b.2) +} + +/// Some Start remains desired, or none. Intent does not establish process state. +pub(crate) fn desired(conn: &Connection, agent: &str) -> Result, String> { + let Some(start) = row(conn, agent, "start")? else { + return Ok(None); + }; + if row(conn, agent, &format!("stop:{}", start.0))?.is_some_and(|stop| newer(&stop, &start)) { + return Ok(None); + } + Ok(Some((start.0, start.2))) +} + +/// Unknown preserves existing local behavior; known supersession blocks every spawn. +pub(crate) fn blocked(conn: &Connection, agent: &str, host: &str) -> Result { + let start = row(conn, agent, "start")?; + let stop = row(conn, agent, &format!("stop:{host}"))?; + Ok(match (start, stop) { + (None, Some(_)) => true, + (None, None) => false, + (Some(start), Some(stop)) if newer(&stop, &start) => true, + (Some(start), _) => start.0 != host, + }) +} + +/// Durable high-water marks are never evicted with diagnostic/result history. +pub(crate) fn admit(conn: &Connection, event: &Event, request: &Request) -> Result { + schema(conn)?; + let action = match request.action { + Action::Start => "start", + Action::Restart => "restart", + Action::Status => "status", + }; + let changed = conn.execute("INSERT INTO desktop_lifecycle_admission VALUES (?1,?2,?3,?4,?5) + ON CONFLICT(agent,host,action) DO UPDATE SET stamp=excluded.stamp,id=excluded.id + WHERE excluded.stamp > desktop_lifecycle_admission.stamp OR + (excluded.stamp = desktop_lifecycle_admission.stamp AND excluded.id < desktop_lifecycle_admission.id)", + params![request.target.agent,request.target.desktop,action,event.created_at.as_secs(),event.id.to_hex()]).map_err(|e| e.to_string())?; + Ok(changed == 1) +} +pub(crate) fn saved(conn: &Connection, id: &str) -> Result, String> { + schema(conn)?; + let raw: Option = conn + .query_row( + "SELECT raw FROM desktop_lifecycle_results WHERE id=?1", + [id], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + raw.map(|s| Event::from_json(s).map_err(|e| e.to_string())) + .transpose() +} +pub(crate) fn save(conn: &mut Connection, id: &str, event: &Event) -> Result<(), String> { + schema(conn)?; + let tx = conn.transaction().map_err(|e| e.to_string())?; + tx.execute( + "INSERT OR IGNORE INTO desktop_lifecycle_results VALUES (?1,?2)", + params![id, event.as_json()], + ) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM desktop_lifecycle_results WHERE rowid NOT IN (SELECT rowid FROM desktop_lifecycle_results ORDER BY rowid DESC LIMIT 256)", []).map_err(|e|e.to_string())?; + tx.commit().map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests; + +pub(crate) fn has_start(conn: &Connection, agent: &str) -> Result { + Ok(row(conn, agent, "start")?.is_some()) +} +pub(crate) fn latest_start( + conn: &Connection, + agent: &str, +) -> Result, String> { + Ok(row(conn, agent, "start")?.map(|(host, _, id)| (host, id))) +} + +/// A newer local Stop invalidates stale Restart even after a subsequent Start. +pub(crate) fn stale_restart( + conn: &Connection, + event: &Event, + request: &Request, +) -> Result { + let command = ( + request.target.desktop.clone(), + event.created_at.as_secs(), + event.id.to_hex(), + ); + Ok(row( + conn, + &request.target.agent, + &format!("stop:{}", request.target.desktop), + )? + .is_some_and(|stop| newer(&stop, &command))) +} + +/// Authenticate and consume before effects; crashes and evicted results never +/// turn an exact retry into a fresh launch or Restart. +pub(crate) fn receive( + conn: &mut Connection, + event: &Event, + keys: &Keys, + community: &str, + desktop: &str, + owned: bool, + effect: impl FnOnce(&Connection, &Request) -> Result, +) -> Result, String> { + let request = Request::read(event, keys, community)?; + observe(conn, event, keys, community)?; + if request.target.desktop != desktop { + return Ok(None); + } + if let Some(saved) = saved(conn, &event.id.to_hex())? { + ResultMessage::read(&saved, keys, event, community)?; + return Ok(Some(saved)); + } + let outcome = if !owned { + Outcome::Failed + } else if !admit(conn, event, &request)? + || (request.action != Action::Status + && (blocked(conn, &request.target.agent, desktop)? + || (request.action == Action::Restart && stale_restart(conn, event, &request)?) + || (request.action == Action::Start + && desired(conn, &request.target.agent)?.map(|(_, id)| id) + != Some(event.id.to_hex())))) + { + Outcome::Unknown + } else { + effect(conn, &request).unwrap_or(if request.action == Action::Status { + Outcome::Unknown + } else { + Outcome::Failed + }) + }; + let result = ResultMessage { + request, + id: event.id.to_hex(), + outcome, + } + .sign(keys)?; + save(conn, &event.id.to_hex(), &result)?; + Ok(Some(result)) +} diff --git a/desktop/src-tauri/src/managed_agents/placement/tests.rs b/desktop/src-tauri/src/managed_agents/placement/tests.rs new file mode 100644 index 00000000000..acf98915e58 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/placement/tests.rs @@ -0,0 +1,284 @@ +use super::*; +use buzz_core_pkg::desktop_lifecycle::{Outcome, ResultMessage}; +use nostr::Timestamp; +fn event(keys: &Keys, host: &str, start: bool, stamp: u64) -> Event { + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: host.repeat(32), + agent: keys.public_key().to_hex(), + }; + let event = if start { + Request { + target, + action: Action::Start, + observed: None, + } + .sign(keys) + .unwrap() + } else { + target.sign(keys).unwrap() + }; + nostr::EventBuilder::new(event.kind, event.content) + .tags(event.tags) + .custom_created_at(Timestamp::from(stamp)) + .sign_with_keys(keys) + .unwrap() +} +#[test] +fn opposite_arrival_and_scoped_stops_converge_without_resurrection() { + let keys = Keys::generate(); + let agent = keys.public_key().to_hex(); + let events = [ + event(&keys, "a", true, 1), + event(&keys, "b", true, 2), + event(&keys, "a", false, 3), + ]; + for order in [vec![0, 1, 2], vec![2, 0, 1], vec![1, 2, 0]] { + let conn = Connection::open_in_memory().unwrap(); + for i in order { + observe(&conn, &events[i], &keys, "wss://one.example").unwrap(); + } + assert_eq!( + desired(&conn, &agent).unwrap(), + Some(("b".repeat(32), events[1].id.to_hex())) + ); + assert!(blocked(&conn, &agent, &"a".repeat(32)).unwrap()); + assert!(!blocked(&conn, &agent, &"b".repeat(32)).unwrap()); + observe( + &conn, + &event(&keys, "b", false, 4), + &keys, + "wss://one.example", + ) + .unwrap(); + assert_eq!(desired(&conn, &agent).unwrap(), None); + assert!(blocked(&conn, &agent, &"b".repeat(32)).unwrap()); + observe(&conn, &events[0], &keys, "wss://one.example").unwrap(); + assert_eq!(desired(&conn, &agent).unwrap(), None); + } +} +#[test] +fn same_second_lower_id_and_future_timestamp_are_authority() { + let keys = Keys::generate(); + let conn = Connection::open_in_memory().unwrap(); + let a = event(&keys, "a", true, 1000); + let b = event(&keys, "b", true, 1000); + for e in [&a, &b, &a] { + observe(&conn, e, &keys, "wss://one.example").unwrap(); + } + let winner = if a.id < b.id { &a } else { &b }; + assert_eq!( + desired(&conn, &keys.public_key().to_hex()) + .unwrap() + .unwrap() + .1, + winner.id.to_hex() + ); + observe( + &conn, + &event(&keys, "c", true, 999), + &keys, + "wss://one.example", + ) + .unwrap(); + assert_eq!( + desired(&conn, &keys.public_key().to_hex()) + .unwrap() + .unwrap() + .1, + winner.id.to_hex() + ); +} +#[test] +fn consumption_survives_restart_and_result_eviction() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("journal.db"); + let keys = Keys::generate(); + let event = event(&keys, "a", true, 1); + let request = Request::read(&event, &keys, "wss://one.example").unwrap(); + let mut conn = Connection::open(&path).unwrap(); + assert!(admit(&conn, &event, &request).unwrap()); + for i in 0..258 { + let result = ResultMessage { + request: request.clone(), + id: event.id.to_hex(), + outcome: Outcome::Unknown, + } + .sign(&keys) + .unwrap(); + save(&mut conn, &i.to_string(), &result).unwrap(); + } + drop(conn); + let conn = Connection::open(&path).unwrap(); + assert!(!admit(&conn, &event, &request).unwrap()); + assert!(saved(&conn, "0").unwrap().is_none()); + assert!(admit(&conn, &super::tests::event(&keys, "a", true, 2), &request).unwrap()); +} + +#[test] +fn receiver_consumes_before_effect_and_never_repeats_restart() { + let keys = Keys::generate(); + let mut conn = Connection::open_in_memory().unwrap(); + let start = event(&keys, "a", true, 10); + let start_request = Request::read(&start, &keys, "wss://one.example").unwrap(); + observe(&conn, &start, &keys, "wss://one.example").unwrap(); + let restart = Request { + action: Action::Restart, + observed: Some("f".repeat(64)), + ..start_request + } + .sign(&keys) + .unwrap(); + let mut effects = 0; + let result = receive( + &mut conn, + &restart, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |conn, request| { + assert!( + !admit(conn, &restart, request).unwrap(), + "effect must see durable consumption" + ); + effects += 1; + Ok(Outcome::Running) + }, + ) + .unwrap() + .unwrap(); + let retry = receive( + &mut conn, + &restart, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("duplicate effect"), + ) + .unwrap() + .unwrap(); + assert_eq!(result, retry); + assert_eq!(effects, 1); + conn.execute("DELETE FROM desktop_lifecycle_results", []) + .unwrap(); + let unknown = receive( + &mut conn, + &restart, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("evicted effect"), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&unknown, &keys, &restart, "wss://one.example") + .unwrap() + .outcome, + Outcome::Unknown + ); +} + +#[test] +fn receiver_rejects_wrong_owner_route_and_superseded_start() { + let keys = Keys::generate(); + let mut conn = Connection::open_in_memory().unwrap(); + let first = event(&keys, "a", true, 1); + let newer = event(&keys, "a", true, 2); + observe(&conn, &newer, &keys, "wss://one.example").unwrap(); + assert!(receive( + &mut conn, + &first, + &Keys::generate(), + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("owner") + ) + .is_err()); + assert!(receive( + &mut conn, + &first, + &keys, + "wss://one.example", + &"b".repeat(32), + true, + |_, _| panic!("route") + ) + .unwrap() + .is_none()); + let result = receive( + &mut conn, + &first, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("stale Start"), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&result, &keys, &first, "wss://one.example") + .unwrap() + .outcome, + Outcome::Unknown + ); + let denied = receive( + &mut conn, + &newer, + &keys, + "wss://one.example", + &"a".repeat(32), + false, + |_, _| panic!("unowned"), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&denied, &keys, &newer, "wss://one.example") + .unwrap() + .outcome, + Outcome::Failed + ); +} + +#[test] +fn receiver_failure_is_saved_without_reinvoking_launch() { + let keys = Keys::generate(); + let mut conn = Connection::open_in_memory().unwrap(); + let start = event(&keys, "a", true, 1); + let result = receive( + &mut conn, + &start, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| Err("native error with private path".into()), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&result, &keys, &start, "wss://one.example") + .unwrap() + .outcome, + Outcome::Failed + ); + let retry = receive( + &mut conn, + &start, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("retry"), + ) + .unwrap() + .unwrap(); + assert_eq!(retry, result); +} From 0f2eb99feeebae4a5dae6fb13098aa509484abe9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 17:46:29 -0400 Subject: [PATCH 16/51] feat(desktop): introduce destination-local keyless launch boundary Signed-off-by: Logan Johnson --- .../src/managed_agents/broker_launch.rs | 181 ++++++++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 41 +++- .../src/managed_agents/runtime_commands.rs | 33 +++- 4 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/broker_launch.rs diff --git a/desktop/src-tauri/src/managed_agents/broker_launch.rs b/desktop/src-tauri/src/managed_agents/broker_launch.rs new file mode 100644 index 00000000000..863e91e13d5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/broker_launch.rs @@ -0,0 +1,181 @@ +//! Destination-local keyless launch boundary. Credentials never cross IPC/relay. +//! Automatic issuance is not in the merged broker contract. Keep the production +//! provider fail-closed until that host-owned adapter is supplied; do not mint a +//! competing bearer token or fall back to exporting the agent key. +use super::ManagedAgentRecord; +use buzz_core_pkg::desktop_lifecycle::Outcome; +use std::process::Command; + +pub(crate) struct LaunchScope<'a> { + pub owner: &'a str, + pub community: &'a str, + pub agent: &'a str, +} + +/// A host-issued session, bound to this destination's independently resolved +/// inputs. No Debug/Serialize: the bearer credential is not diagnostic data. +pub(crate) struct BrokerSession { + owner: String, + community: String, + agent: String, + endpoint: String, + credential: zeroize::Zeroizing, + channels: Vec, + expires_at: u64, +} + +impl BrokerSession { + /// Future host provisioning adapter calls this with its authenticated reply, + /// never values from an incoming lifecycle command or user environment. + #[allow(dead_code)] // consumed by the pending automatic-issuance host adapter + pub(crate) fn from_host( + scope: LaunchScope<'_>, + endpoint: String, + credential: String, + channels: Vec, + expires_at: u64, + ) -> Result { + let url = url::Url::parse(&endpoint).map_err(|_| "Invalid broker endpoint")?; + if !matches!(url.scheme(), "https" | "http") + || url.host_str().is_none() + || (url.scheme() == "http" + && !matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"))) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || credential.trim().is_empty() + || channels.is_empty() + || channels.len() > 256 + || channels.iter().any(|c| uuid::Uuid::parse_str(c).is_err()) + { + return Err("Invalid broker provisioning".into()); + } + let session = Self { + owner: scope.owner.into(), + community: scope.community.into(), + agent: scope.agent.into(), + endpoint, + credential: zeroize::Zeroizing::new(credential), + channels, + expires_at, + }; + session.validate(scope)?; + Ok(session) + } + pub(crate) fn validate(&self, scope: LaunchScope<'_>) -> Result<(), String> { + if self.owner != scope.owner + || self.community != scope.community + || self.agent != scope.agent + || self.expires_at <= nostr::Timestamp::now().as_secs().saturating_add(30) + { + return Err("Broker session expired or scope changed".into()); + } + Ok(()) + } + /// Applied last, after local user/provider configuration. Never let saved + /// environment turn a keyless launch into keyful authentication. + pub(crate) fn apply( + &self, + command: &mut Command, + scope: LaunchScope<'_>, + ) -> Result<(), String> { + self.validate(scope)?; + for key in [ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + "BUZZ_RELAY_URL", + "BUZZ_ACP_RELAY_URL", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "GIT_CONFIG_COUNT", + ] { + command.env_remove(key); + } + command + .env("BUZZ_AGENT_MODE", "broker") + .env("BUZZ_BROKER_URL", &self.endpoint) + .env("BUZZ_BROKER_CREDENTIAL", self.credential.as_str()) + .env("BUZZ_BROKER_RELAY_URL", &self.community) + .env("BUZZ_ACP_AGENT_OWNER", &self.owner) + .env("BUZZ_ACP_CHANNELS", self.channels.join(",")) + .env("BUZZ_ACP_RESPOND_TO", "owner-only") + .env("BUZZ_ACP_ALLOWED_RESPOND_TO", "owner-only"); + Ok(()) + } +} + +/// Explicit external integration gate, not a fabricated successful launch. +pub(crate) fn provision( + _scope: LaunchScope<'_>, + _record: &ManagedAgentRecord, +) -> Result { + Err(Outcome::ProvisioningUnavailable) +} + +#[cfg(test)] +mod tests { + use super::*; + fn scope() -> LaunchScope<'static> { + LaunchScope { + owner: "owner", + community: "wss://one.example", + agent: "agent", + } + } + #[test] + fn scope_expiry_and_final_environment_are_enforced() { + let session = BrokerSession::from_host( + scope(), + "https://broker.example".into(), + "secret".into(), + vec![uuid::Uuid::new_v4().to_string()], + nostr::Timestamp::now().as_secs() + 300, + ) + .unwrap(); + assert!(session + .validate(LaunchScope { + community: "wss://other.example", + ..scope() + }) + .is_err()); + let mut command = Command::new("unused"); + command + .env("BUZZ_PRIVATE_KEY", "must-not-escape") + .env("NOSTR_PRIVATE_KEY", "must-not-escape") + .env("BUZZ_AGENT_MODE", "local"); + session.apply(&mut command, scope()).unwrap(); + let env: std::collections::BTreeMap<_, _> = command.get_envs().collect(); + assert_eq!( + env.get(std::ffi::OsStr::new("BUZZ_PRIVATE_KEY")), + Some(&None) + ); + assert_eq!( + env.get(std::ffi::OsStr::new("NOSTR_PRIVATE_KEY")), + Some(&None) + ); + assert_eq!( + env.get(std::ffi::OsStr::new("BUZZ_AGENT_MODE")), + Some(&Some(std::ffi::OsStr::new("broker"))) + ); + assert!(BrokerSession::from_host( + scope(), + "http://remote.example".into(), + "secret".into(), + vec![uuid::Uuid::new_v4().to_string()], + nostr::Timestamp::now().as_secs() + 300 + ) + .is_err()); + assert!(BrokerSession::from_host( + scope(), + "https://broker.example".into(), + "secret".into(), + vec![uuid::Uuid::new_v4().to_string()], + 0 + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index e859344e4c7..987e16a4f41 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -12,6 +12,7 @@ mod agent_description; pub(crate) use agent_description::{effective_agent_description, record_effective_description}; mod backend; pub(crate) mod bestie_assignment; +pub(crate) mod broker_launch; pub(crate) mod claude_config; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 1eec6eee979..8d97fd1fff1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -452,10 +452,39 @@ pub fn spawn_agent_child( owner_hex: Option<&str>, replay_floor_unix: Option, resume: Option<&super::remote_stop::ResumeTicket>, +) -> Result { + spawn_agent_child_with_broker( + app, + record, + relay_url, + lazy, + owner_hex, + replay_floor_unix, + resume, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_agent_child_with_broker( + app: &AppHandle, + record: &ManagedAgentRecord, + relay_url: &str, + lazy: bool, + owner_hex: Option<&str>, + replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, + broker: Option<&super::broker_launch::BrokerSession>, ) -> Result { let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; super::remote_stop::check_launch(app, &key, owner_hex, resume)?; - if let Some(error) = spawn_key_refusal(record) { + if let Some(session) = broker { + session.validate(super::broker_launch::LaunchScope { + owner: owner_hex.ok_or("Desktop owner unavailable")?, + community: relay_url, + agent: &record.pubkey, + })?; + } else if let Some(error) = spawn_key_refusal(record) { return Err(error); } let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; @@ -830,6 +859,16 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } + if let Some(session) = broker { + session.apply( + &mut command, + super::broker_launch::LaunchScope { + owner: owner_hex.ok_or("Desktop owner unavailable")?, + community: relay_url, + agent: &record.pubkey, + }, + )?; + } let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index eacb7ba6bf6..665e3c5d426 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -5,10 +5,10 @@ use tauri::{AppHandle, Emitter, Manager}; use super::{ agent_readiness, 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, + record_agent_command, resolve_effective_agent_env, save_managed_agents, terminate_process, + terminate_untracked_pair_runtime, write_agent_runtime_receipt, AgentReadiness, BackendKind, + ManagedAgentPairRuntime, ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, + ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, }; use crate::app_state::AppState; @@ -261,6 +261,28 @@ fn start_pair( .managed_agent_runtime_transition .lock() .map_err(|e| e.to_string())?; + start_pair_locked( + pubkey, + relay_url, + lazy, + expected_updated_at, + explicit_start, + None, + app.clone(), + ) +} + +// Caller owns the transition lock across admission, effect, receipt and result. +pub(crate) fn start_pair_locked( + pubkey: String, + relay_url: String, + lazy: bool, + expected_updated_at: Option<&str>, + explicit_start: bool, + broker: Option<&super::broker_launch::BrokerSession>, + app: AppHandle, +) -> Result { + let state = app.state::(); if state.shutdown_started.load(Ordering::Acquire) { return Err("desktop shutdown has started".into()); } @@ -305,7 +327,7 @@ fn start_pair( } else { None }; - let mut process = spawn_agent_child( + let mut process = super::spawn_agent_child_with_broker( &app, record, &key.relay_url, @@ -313,6 +335,7 @@ fn start_pair( owner.as_deref(), None, resume.as_ref(), + broker, )?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { From faee0caceae90956e759db5a031d13ba4dfcf577 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 18:10:06 -0400 Subject: [PATCH 17/51] fix(multiverse): defer provisioning stub until receiver slice Signed-off-by: Logan Johnson --- desktop/src-tauri/src/managed_agents/broker_launch.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/broker_launch.rs b/desktop/src-tauri/src/managed_agents/broker_launch.rs index 863e91e13d5..b2d33051daf 100644 --- a/desktop/src-tauri/src/managed_agents/broker_launch.rs +++ b/desktop/src-tauri/src/managed_agents/broker_launch.rs @@ -2,8 +2,6 @@ //! Automatic issuance is not in the merged broker contract. Keep the production //! provider fail-closed until that host-owned adapter is supplied; do not mint a //! competing bearer token or fall back to exporting the agent key. -use super::ManagedAgentRecord; -use buzz_core_pkg::desktop_lifecycle::Outcome; use std::process::Command; pub(crate) struct LaunchScope<'a> { @@ -108,14 +106,6 @@ impl BrokerSession { } } -/// Explicit external integration gate, not a fabricated successful launch. -pub(crate) fn provision( - _scope: LaunchScope<'_>, - _record: &ManagedAgentRecord, -) -> Result { - Err(Outcome::ProvisioningUnavailable) -} - #[cfg(test)] mod tests { use super::*; From a52c0537fcbe8be675af2ce052625b1e95375d67 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 17:46:29 -0400 Subject: [PATCH 18/51] feat(desktop): receive authenticated Start and current-host Restart Signed-off-by: Logan Johnson --- .../src/commands/desktop_lifecycle.rs | 343 ++++++++++++++++++ .../src-tauri/src/commands/desktop_stop.rs | 64 ++-- desktop/src-tauri/src/commands/mod.rs | 4 +- desktop/src-tauri/src/lib.rs | 5 + desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/remote_stop.rs | 16 + 6 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 desktop/src-tauri/src/commands/desktop_lifecycle.rs diff --git a/desktop/src-tauri/src/commands/desktop_lifecycle.rs b/desktop/src-tauri/src/commands/desktop_lifecycle.rs new file mode 100644 index 00000000000..a46ac7822a5 --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_lifecycle.rs @@ -0,0 +1,343 @@ +//! Trusted Desktop lifecycle adapter. Historical projection never launches. +use super::{ + desktop_profiles::scope, + desktop_stop::{local_id, owned_local}, +}; +use crate::{ + app_state::AppState, + managed_agents::{self, broker_launch, placement, retention::open_retention_db}, +}; +use buzz_core_pkg::{ + desktop_lifecycle::{Action, Outcome, Request, ResultMessage}, + desktop_stop::StopTarget, +}; +use nostr::{Event, JsonUtil}; +use rusqlite::{params, OptionalExtension}; +use tauri::{AppHandle, Manager}; + +#[tauri::command] +pub fn prepare_desktop_lifecycle( + app: AppHandle, + owner: String, + community: String, + desktop: String, + agent: String, + action: Action, + observed: Option, +) -> Result { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + let event = Request { + target: StopTarget { + v: 1, + community, + desktop, + agent, + }, + action, + observed, + } + .sign(&scope.owner_keys)?; + let conn = open_retention_db(&scope.db_path)?; + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_lifecycle_outgoing (slot INTEGER PRIMARY KEY CHECK(slot=1),raw TEXT NOT NULL)").map_err(|e|e.to_string())?; + conn.execute("INSERT INTO desktop_lifecycle_outgoing VALUES(1,?1) ON CONFLICT(slot) DO UPDATE SET raw=excluded.raw",[event.as_json()]).map_err(|e|e.to_string())?; + Ok(event) +} + +/// Authenticated projection batches only; no Stop/Restart command replay. Stops +/// caused by superseded placement reuse the ordinary local lifecycle owner. +#[tauri::command] +pub async fn observe_desktop_placement( + app: AppHandle, + owner: String, + community: String, + events: Vec, + reconcile: bool, +) -> Result<(), String> { + if events.len() > 256 { + return Err("Too many placement events".into()); + } + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let scope = scope(&app, &state, &owner, &community)?; + let mut conn = open_retention_db(&scope.db_path)?; + let desktop = local_id(&mut conn, &scope)?; + let mut agents = std::collections::BTreeSet::new(); + for event in events { + agents.insert(placement::observe( + &conn, + &event, + &scope.owner_keys, + &community, + )?); + } + if !reconcile { + return Ok(()); + } + placement::schema(&conn)?; + let mut query = conn + .prepare("SELECT DISTINCT agent FROM desktop_placement") + .map_err(|e| e.to_string())?; + for row in query + .query_map([], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())? + { + agents.insert(row.map_err(|e| e.to_string())?); + } + for agent in agents { + if placement::blocked(&conn, &agent, &desktop)? + && owned_local(&app, &state, &owner, &agent)? + { + // Merely learning old Stop while no child exists performs no effect. + if generation(&app, &state, &agent, &community).map_or(true, |g| g.is_some()) { + managed_agents::stop_pair_locked(agent, community.clone(), app.clone())?; + } + } + } + Ok(()) + }) + .await + .map_err(|e| format!("Placement task failed: {e}"))? +} + +#[tauri::command] +pub fn read_desktop_placement( + app: AppHandle, + owner: String, + community: String, + agent: String, +) -> Result, String> { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + placement::latest_start(&open_retention_db(&scope.db_path)?, &agent) +} + +fn generation( + app: &AppHandle, + state: &AppState, + agent: &str, + community: &str, +) -> Result, String> { + let key = managed_agents::ManagedAgentRuntimeKey::new(agent, community)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + if let Some(runtime) = runtimes.get_mut(&key) { + if runtime + .child + .try_wait() + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(Some(runtime.start_nonce.clone())); + } + } + drop(runtimes); + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = managed_agents::load_managed_agents(app)?; + let legacy = records + .iter() + .find(|r| r.pubkey == agent) + .and_then(|r| r.runtime_pid); + let dir = managed_agents::managed_agents_base_dir(app)?.join("agent-pids"); + // A receipt may represent a surviving untracked child. Never turn a missing + // in-memory handle, unreadable receipt, or legacy live PID into Stopped. + if legacy.is_some_and(managed_agents::process_is_running) + || dir + .join(format!("{}.json", key.runtime_id())) + .try_exists() + .map_err(|e| e.to_string())? + || dir + .join(format!("{agent}.pid")) + .try_exists() + .map_err(|e| e.to_string())? + { + return Err("Local process state is untracked; use ordinary Desktop Stop".into()); + } + Ok(None) +} + +fn status( + app: &AppHandle, + conn: &rusqlite::Connection, + state: &AppState, + event: &Event, + request: &Request, +) -> Result { + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_status_generation (id TEXT PRIMARY KEY,nonce TEXT NOT NULL,observed INTEGER NOT NULL)").map_err(|e|e.to_string())?; + let Some(nonce) = generation(app, state, &request.target.agent, &request.target.community)? + else { + return Ok(Outcome::Stopped); + }; + conn.execute( + "INSERT OR REPLACE INTO desktop_status_generation VALUES(?1,?2,?3)", + params![event.id.to_hex(), nonce, nostr::Timestamp::now().as_secs()], + ) + .map_err(|e| e.to_string())?; + conn.execute("DELETE FROM desktop_status_generation WHERE rowid NOT IN (SELECT rowid FROM desktop_status_generation ORDER BY rowid DESC LIMIT 256)",[]).map_err(|e|e.to_string())?; + Ok(Outcome::Running) +} +fn current_observation( + app: &AppHandle, + conn: &rusqlite::Connection, + state: &AppState, + request: &Request, +) -> Result { + let Some(id) = request.observed.as_deref() else { + return Ok(false); + }; + // A request cannot create this local record; only a real Status observation can. + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_status_generation (id TEXT PRIMARY KEY,nonce TEXT NOT NULL,observed INTEGER NOT NULL)").map_err(|e|e.to_string())?; + let saved: Option<(String, u64)> = conn + .query_row( + "SELECT nonce,observed FROM desktop_status_generation WHERE id=?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + Ok(match saved { + Some((nonce, stamp)) if nostr::Timestamp::now().as_secs().saturating_sub(stamp) <= 30 => { + generation(app, state, &request.target.agent, &request.target.community)?.as_deref() + == Some(nonce.as_str()) + } + _ => false, + }) +} + +#[tauri::command] +pub async fn receive_desktop_lifecycle( + app: AppHandle, + owner: String, + community: String, + event: Event, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let scope = scope(&app, &state, &owner, &community)?; + let request = Request::read(&event, &scope.owner_keys, &community)?; + let mut conn = open_retention_db(&scope.db_path)?; + let desktop = local_id(&mut conn, &scope)?; + let owned = owned_local(&app, &state, &owner, &request.target.agent)?; + placement::receive( + &mut conn, + &event, + &scope.owner_keys, + &community, + &desktop, + owned, + |conn, request| { + if request.action == Action::Status { + status(&app, conn, &state, &event, request) + } else { + execute(&app, &state, conn, &owner, request) + } + }, + ) + }) + .await + .map_err(|e| format!("Desktop lifecycle task failed: {e}"))? +} + +fn execute( + app: &AppHandle, + state: &AppState, + conn: &rusqlite::Connection, + owner: &str, + request: &Request, +) -> Result { + let target = &request.target; + if request.action == Action::Restart && !current_observation(app, conn, state, request)? { + return Ok(Outcome::Unknown); + } + if request.action == Action::Start + && generation(app, state, &target.agent, &target.community)?.is_some() + { + return Ok(Outcome::Running); + } + let record = { + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + managed_agents::load_managed_agents(app)? + .into_iter() + .find(|r| r.pubkey == target.agent) + .ok_or("Agent is not provisioned on this Desktop")? + }; + // Provision before destructive Restart Stop. No supported session: leave the + // existing child running and return the precise non-secret missing capability. + let broker = match broker_launch::provision( + broker_launch::LaunchScope { + owner, + community: &target.community, + agent: &target.agent, + }, + &record, + ) { + Ok(b) => b, + Err(outcome) => return Ok(outcome), + }; + if request.action == Action::Restart + && managed_agents::stop_pair_locked( + target.agent.clone(), + target.community.clone(), + app.clone(), + ) + .is_err() + { + return Ok(Outcome::Failed); + } + // Existing transition lock spans final intent check, ordinary launch and registration. + if placement::blocked(conn, &target.agent, &target.desktop)? { + return Ok(Outcome::Unknown); + } + match managed_agents::start_pair_locked( + target.agent.clone(), + target.community.clone(), + true, + None, + true, + Some(&broker), + app.clone(), + ) { + Ok(_) => Ok(Outcome::Running), + Err(_) => Ok(Outcome::Failed), + } +} + +#[tauri::command] +pub fn read_desktop_lifecycle_results( + app: AppHandle, + owner: String, + community: String, + request: Event, + events: Vec, +) -> Result { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + Request::read(&request, &scope.owner_keys, &community)?; + if events.len() > 16 { + return Err("Too many lifecycle results".into()); + } + let mut outcome = Outcome::Unknown; + for event in events { + let result = ResultMessage::read(&event, &scope.owner_keys, &request, &community)?; + if result.outcome != Outcome::Unknown { + outcome = result.outcome; + } + } + Ok(outcome) +} diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs index 11ab7fe4e14..2c9623fb29a 100644 --- a/desktop/src-tauri/src/commands/desktop_stop.rs +++ b/desktop/src-tauri/src/commands/desktop_stop.rs @@ -12,7 +12,7 @@ use nostr::{Event, JsonUtil, PublicKey}; use serde_json::{json, Value}; use tauri::{AppHandle, Manager}; -fn local_id( +pub(crate) fn local_id( conn: &mut rusqlite::Connection, scope: &managed_agents::retention::RetentionScope, ) -> Result { @@ -72,31 +72,29 @@ pub async fn receive_desktop_stop( let target = StopTarget::read(&event, &scope.owner_keys, &community)?; let mut conn = open_retention_db(&scope.db_path)?; let desktop = local_id(&mut conn, &scope)?; + managed_agents::placement::observe(&conn, &event, &scope.owner_keys, &community)?; if desktop != target.desktop { return Ok(None); } // Local possession alone is insufficient after an account switch: // verify the stored agent's owner delegation against the request author. - let owned = { - let _store = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let records = managed_agents::load_managed_agents(&app)?; - records - .iter() - .find(|r| r.pubkey == target.agent) - .is_some_and(|r| { - r.backend == managed_agents::BackendKind::Local - && r.auth_tag - .as_deref() - .and_then(|tag| { - let key = PublicKey::from_hex(&target.agent).ok()?; - buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &key).ok() - }) - .is_some_and(|key| key.to_hex() == owner) - }) - }; + if let Some(raw) = remote_stop::saved_result(&conn, &event.id.to_hex())? { + let saved = Event::from_json(raw).map_err(|e| e.to_string())?; + StopResult::read(&saved, &scope.owner_keys, &event, &community)?; + return Ok(Some(saved)); + } + if managed_agents::placement::desired(&conn, &target.agent)? + .is_some_and(|(host, _)| host == desktop) + { + return StopResult { + target, + request: event.id.to_hex(), + outcome: StopOutcome::Unknown, + } + .sign(&scope.owner_keys) + .map(Some); + } + let owned = owned_local(&app, &state, &owner, &target.agent)?; remote_stop::receive( &mut conn, &event, @@ -143,3 +141,27 @@ pub fn read_desktop_stop_results( } Ok(json!(outcome)) } + +/// Local possession/profile alone never establishes owner authority. +pub(super) fn owned_local( + app: &AppHandle, + state: &AppState, + owner: &str, + agent: &str, +) -> Result { + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = managed_agents::load_managed_agents(app)?; + Ok(records.iter().find(|r| r.pubkey == agent).is_some_and(|r| { + r.backend == managed_agents::BackendKind::Local + && r.auth_tag + .as_deref() + .and_then(|tag| { + let key = PublicKey::from_hex(agent).ok()?; + buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &key).ok() + }) + .is_some_and(|key| key.to_hex() == owner) + })) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 5a5c2566ac1..a0bbdd76c91 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -19,8 +19,9 @@ mod channel_window; mod channels; mod clipboard; mod desktop_capabilities; +mod desktop_lifecycle; mod desktop_profiles; -mod desktop_stop; +pub(crate) mod desktop_stop; mod dms; mod engrams; mod export_util; @@ -96,6 +97,7 @@ pub use channel_window::*; pub use channels::*; pub use clipboard::*; pub use desktop_capabilities::*; +pub use desktop_lifecycle::*; pub use desktop_profiles::*; pub use desktop_stop::*; pub use dms::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2eb43d10670..67b82af3dc1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -554,6 +554,11 @@ pub fn run() { get_identity, prepare_desktop_profile, prepare_desktop_stop, + prepare_desktop_lifecycle, + observe_desktop_placement, + read_desktop_placement, + receive_desktop_lifecycle, + read_desktop_lifecycle_results, receive_desktop_stop, read_desktop_stop_results, read_desktop_profiles, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 987e16a4f41..db259e80a71 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -28,6 +28,7 @@ pub(crate) mod parallelism; mod persona_avatars; pub(crate) mod persona_events; mod personas; +pub(crate) mod placement; #[cfg(windows)] mod process_lifecycle; pub(crate) mod readiness; diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs index 93e3e9b5adc..55d03649004 100644 --- a/desktop/src-tauri/src/managed_agents/remote_stop.rs +++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs @@ -167,6 +167,22 @@ pub(crate) fn check_launch( } let conn = connection(app, key, ¤t_owner)?; schema(&conn)?; + let scope = super::retention::RetentionScope { + db_path: scoped_retention_db_path( + &super::managed_agents_base_dir(app)?, + &key.relay_url, + ¤t_owner, + ), + relay_url: key.relay_url.clone(), + owner_keys: state.signing_keys()?, + }; + let mut local_conn = connection(app, key, ¤t_owner)?; + let host = crate::commands::desktop_stop::local_id(&mut local_conn, &scope)?; + if super::placement::blocked(&conn, &key.pubkey, &host)? + && (resume.is_none() || super::placement::has_start(&conn, &key.pubkey)?) + { + return Err("This Desktop is no longer the selected running destination. Use explicit Start on this Desktop.".into()); + } let row: Option<(String, bool)> = conn .query_row( "SELECT event_id, blocked FROM desktop_stop_fence WHERE agent=?1", From 30ce5b95f39855da2814889c0d6821d666573be3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 18:10:23 -0400 Subject: [PATCH 19/51] fix(multiverse): introduce provisioning stub with its receiver Signed-off-by: Logan Johnson --- desktop/src-tauri/src/managed_agents/broker_launch.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/broker_launch.rs b/desktop/src-tauri/src/managed_agents/broker_launch.rs index b2d33051daf..863e91e13d5 100644 --- a/desktop/src-tauri/src/managed_agents/broker_launch.rs +++ b/desktop/src-tauri/src/managed_agents/broker_launch.rs @@ -2,6 +2,8 @@ //! Automatic issuance is not in the merged broker contract. Keep the production //! provider fail-closed until that host-owned adapter is supplied; do not mint a //! competing bearer token or fall back to exporting the agent key. +use super::ManagedAgentRecord; +use buzz_core_pkg::desktop_lifecycle::Outcome; use std::process::Command; pub(crate) struct LaunchScope<'a> { @@ -106,6 +108,14 @@ impl BrokerSession { } } +/// Explicit external integration gate, not a fabricated successful launch. +pub(crate) fn provision( + _scope: LaunchScope<'_>, + _record: &ManagedAgentRecord, +) -> Result { + Err(Outcome::ProvisioningUnavailable) +} + #[cfg(test)] mod tests { use super::*; From 8b26bb20b587e98457f001773f6b98afe7562335 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 17:46:29 -0400 Subject: [PATCH 20/51] feat(desktop): coordinate failure-final Move and exact lifecycle retry Signed-off-by: Logan Johnson --- .../features/agents/desktopLifecycle.test.mjs | 240 +++++++++++++ .../src/features/agents/desktopLifecycle.ts | 334 ++++++++++++++++++ desktop/src/shared/api/relayClientSession.ts | 5 + 3 files changed, 579 insertions(+) create mode 100644 desktop/src/features/agents/desktopLifecycle.test.mjs create mode 100644 desktop/src/features/agents/desktopLifecycle.ts diff --git a/desktop/src/features/agents/desktopLifecycle.test.mjs b/desktop/src/features/agents/desktopLifecycle.test.mjs new file mode 100644 index 00000000000..b15daccfcff --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycle.test.mjs @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { lifecycleClient, receiveLifecycle } from "./desktopLifecycle.ts"; +const scope = { owner: "owner", community: "wss://one.example" }; +const tick = () => new Promise((resolve) => setImmediate(resolve)); +function fixture() { + let epoch = 0, + connection = 1, + active = true, + live; + let placement = ["source", "selection"], + stopOutcome = "stopped"; + let lifecycleOutcome = "running", + history = [], + ackLost = false; + const prepared = [], + sent = [], + calls = [], + errors = []; + const ipc = async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push([command, args]); + if (command === "observe_desktop_placement") return; + if (command === "read_desktop_placement") return placement; + if ( + command === "prepare_desktop_lifecycle" || + command === "prepare_desktop_stop" + ) { + const request = { + id: `request-${prepared.length}`, + kind: command.endsWith("_stop") ? 50180 : 50182, + ...args, + }; + prepared.push(request); + return request; + } + if (command === "read_desktop_lifecycle_results") + return args.request.action === "status" ? "running" : lifecycleOutcome; + if (command === "read_desktop_stop_results") return stopOutcome; + if (command === "receive_desktop_lifecycle") + return { id: "result", kind: 50183 }; + throw Error(command); + }; + const relay = { + getSessionEpoch: () => epoch, + getConnectionGeneration: () => connection, + fetchEvents: async (filter) => + filter.kinds.includes(50182) ? history : [], + publishEvent: async (event, _timeout, _failure, check) => { + check(); + sent.push(event); + if (ackLost) throw Error("ACK lost"); + }, + subscribeLive: async (filter, callback) => { + assert.deepEqual(filter, { + kinds: [50182, 50180], + authors: [scope.owner], + limit: 0, + }); + live = callback; + return () => { + live = undefined; + }; + }, + }; + return { + ipc, + relay, + prepared, + sent, + calls, + errors, + client: () => lifecycleClient(scope, () => active, ipc, relay), + changeScope: () => { + epoch++; + }, + disconnect: () => { + connection++; + }, + unmount: () => { + active = false; + }, + stop: (value) => { + stopOutcome = value; + }, + outcome: (value) => { + lifecycleOutcome = value; + }, + place: (value) => { + placement = value; + }, + history: (value) => { + history = value; + }, + loseAck: () => { + ackLost = true; + }, + deliver: (event) => live?.(event), + }; +} + +test("lost ACK/result permits explicit exact-byte retry, not a fresh Start", async () => { + const f = fixture(), + client = f.client(); + const request = await client.start("destination", "agent"); + f.loseAck(); + f.outcome("unknown"); + assert.equal(await client.send(request, 0), "unknown"); + f.outcome("running"); + assert.equal(await client.send(request, 1), "running"); + assert.equal(f.prepared.length, 1); + assert.deepEqual(f.sent, [request, request]); + assert.equal(f.sent[0], f.sent[1]); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); +}); + +for (const interruption of ["changeScope", "disconnect", "unmount"]) { + test(`${interruption} during Stop cancels Move before destination prepare or send`, async () => { + const f = fixture(), + client = f.client(); + const publish = f.relay.publishEvent; + f.relay.publishEvent = async (...args) => { + await publish(...args); + if (args[0].kind === 50180) f[interruption](); + }; + await assert.rejects( + client.move("agent", "destination", ["source"], () => {}), + /scope changed/, + ); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); + }); +} + +test("failed Stop is final even if a successful outcome appears later", async () => { + const f = fixture(); + f.stop("failed"); + await assert.rejects( + f.client().move("agent", "destination", ["source"], () => {}), + /will not continue later/, + ); + f.stop("stopped"); + await tick(); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); +}); + +test("unconfirmed Stop exhausts polling without storing any future Start", async () => { + const f = fixture(); + f.stop("unknown"); + const timer = globalThis.setTimeout; + globalThis.setTimeout = (fn) => { + queueMicrotask(fn); + return 0; + }; + try { + await assert.rejects( + f.client().move("agent", "destination", ["source"], () => {}), + /Could not confirm source Stop/, + ); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); + } finally { + globalThis.setTimeout = timer; + } +}); + +test("Move dispatches Start only after Stop success and unchanged placement", async () => { + const f = fixture(); + assert.equal( + await f.client().move("agent", "destination", ["source"], () => {}), + "running", + ); + assert.deepEqual( + f.sent.map((r) => r.action ?? "stop"), + ["status", "stop", "start"], + ); + assert.equal(f.sent.at(-1).desktop, "destination"); + const stopRead = f.calls.findIndex( + ([c]) => c === "read_desktop_stop_results", + ); + const startPrepare = f.calls.findIndex( + ([c, a]) => c === "prepare_desktop_lifecycle" && a.action === "start", + ); + assert.ok(stopRead < startPrepare); +}); + +test("another Desktop's new placement supersedes an in-flight Move", async () => { + const f = fixture(), + publish = f.relay.publishEvent; + f.relay.publishEvent = async (...args) => { + await publish(...args); + if (args[0].kind === 50180) f.place(["third", "new-selection"]); + }; + await assert.rejects( + f.client().move("agent", "destination", ["source"], () => {}), + /Placement changed/, + ); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); +}); + +test("Restart resolves current host and binds its Status observation, not destination", async () => { + const f = fixture(); + const request = await f.client().restart("agent", ["source", "other"]); + assert.equal(request.desktop, "source"); + assert.equal(request.observed, f.prepared[0].id); + assert.equal(request.action, "restart"); +}); + +test("receiver projects history without executing it and invalidates live work on disconnect", async () => { + const f = fixture(); + f.history([{ id: "historical", kind: 50182 }]); + const close = await receiveLifecycle( + scope, + () => true, + (e) => f.errors.push(e), + f.ipc, + f.relay, + ); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); + f.deliver({ id: "live", kind: 50182 }); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 1, + ); + f.disconnect(); + f.deliver({ id: "late", kind: 50182 }); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 1, + ); + assert.equal(f.errors.length, 1); + close(); +}); diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts new file mode 100644 index 00000000000..7af772aa181 --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycle.ts @@ -0,0 +1,334 @@ +import { invoke } from "@tauri-apps/api/core"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; +import { + DESKTOP_STOP, + prepareStop, + readStopOutcome, + sendStop, +} from "./desktopStop"; + +export const DESKTOP_LIFECYCLE = 50182; +export const DESKTOP_LIFECYCLE_RESULT = 50183; +export type LifecycleAction = "start" | "restart" | "status"; +export type LifecycleOutcome = + | "running" + | "stopped" + | "provisioning_unavailable" + | "failed" + | "unknown"; +export type CurrentHost = { desktop: string; observation: string }; + +/** Captures identity and connection generation across every asynchronous step. */ +export function lifecycleClient( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +) { + const epoch = relay.getSessionEpoch(); + const connection = relay.getConnectionGeneration(); + const check = () => { + if ( + !active() || + relay.getSessionEpoch() !== epoch || + relay.getConnectionGeneration() !== connection + ) + throw new Error("Desktop lifecycle scope changed"); + }; + const prepare = async ( + desktop: string, + agent: string, + action: LifecycleAction, + observed: string | null = null, + ) => { + check(); + const request = await ipc("prepare_desktop_lifecycle", { + ...scope, + desktop, + agent, + action, + observed, + }); + check(); + return request; + }; + const read = async (request: RelayEvent) => { + check(); + const events = await relay.fetchEvents({ + kinds: [DESKTOP_LIFECYCLE_RESULT], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + check(); + const outcome = await ipc( + "read_desktop_lifecycle_results", + { ...scope, request, events }, + ); + check(); + return outcome; + }; + const send = async ( + request: RelayEvent, + attempts = 15, + ): Promise => { + check(); + try { + await relay.publishEvent( + request, + "Delivery unconfirmed", + "Delivery failed", + check, + ); + } catch { + check(); /* Lost ACK may still have a signed result. */ + } + for (let i = 0; i < attempts; i++) { + check(); + const outcome = await read(request); + check(); + if (outcome !== "unknown") return outcome; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return "unknown"; + }; + const sync = async () => { + let until: number | undefined; + let before_id: string | undefined; + for (let page = 0; page < 64; page++) { + check(); + const events = await relay.fetchEvents({ + kinds: [DESKTOP_STOP, DESKTOP_LIFECYCLE], + authors: [scope.owner], + limit: 256, + until, + before_id, + }); + check(); + // No effects while a partial page could still hide a dominating Start. + await ipc("observe_desktop_placement", { + ...scope, + events, + reconcile: false, + }); + check(); + if (events.length < 256) { + await ipc("observe_desktop_placement", { + ...scope, + events: [], + reconcile: true, + }); + check(); + return; + } + const last = events.at(-1); + if (!last || last.id === before_id) + throw new Error("Placement history cursor did not advance"); + until = last.created_at; + before_id = last.id; + } + throw new Error( + "Placement history is incomplete; no launch was dispatched", + ); + }; + const current = async ( + agent: string, + desktops: string[], + ): Promise => { + await sync(); + check(); + const desired = await ipc<[string, string] | null>( + "read_desktop_placement", + { ...scope, agent }, + ); + check(); + // Probe actual native state, never infer current from last-heard/profile. + const candidates = desired ? [desired[0]] : [...new Set(desktops)]; + if (!candidates.length || candidates.length > 32) + throw new Error("Current Desktop is unknown"); + const observations = await Promise.all( + candidates.map(async (desktop) => { + const request = await prepare(desktop, agent, "status"); + return { + desktop, + observation: request.id, + outcome: await send(request, 3), + }; + }), + ); + check(); + const running = observations.filter((o) => o.outcome === "running"); + if ( + running.length !== 1 || + observations.some( + (o) => o.outcome !== "running" && o.outcome !== "stopped", + ) + ) + throw new Error( + "Current Desktop is unknown or ambiguous; choose explicit Start instead", + ); + return running[0]; + }; + const start = async (desktop: string, agent: string) => { + await sync(); + return prepare(desktop, agent, "start"); + }; + const restart = async (agent: string, desktops: string[]) => { + const host = await current(agent, desktops); + check(); + return prepare(host.desktop, agent, "restart", host.observation); + }; + /** Failed/unconfirmed Move is terminal in this invocation. No saved future + * Start, background callback, reopen replay or retry of a failed Move. */ + const move = async ( + agent: string, + destination: string, + desktops: string[], + onStage: (stage: string) => void, + ): Promise => { + const host = await current(agent, desktops); + check(); + if (host.desktop === destination) + throw new Error("Agent is already on the selected Desktop"); + const before = await ipc<[string, string] | null>( + "read_desktop_placement", + { ...scope, agent }, + ); + check(); + const stop = await prepareStop( + scope, + host.desktop, + agent, + active, + ipc, + relay, + ); + check(); + onStage( + "Waiting for source Desktop Stop; destination has not been started.", + ); + try { + await sendStop(scope, stop, active, relay); + } catch { + check(); + } + let stopped = false; + for (let i = 0; i < 15; i++) { + const result = await readStopOutcome(scope, stop, active, ipc, relay); + check(); + if (result === "failed") break; + if (result === "stopped") { + stopped = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (!stopped) + throw new Error( + "Could not confirm source Stop; destination was not started. This Move will not continue later.", + ); + await sync(); + check(); + const after = await ipc<[string, string] | null>("read_desktop_placement", { + ...scope, + agent, + }); + check(); + // Stop may clear source, but another device's new placement must win. + if (after && (!before || after[1] !== before[1])) + throw new Error( + "Placement changed during Move; destination was not started", + ); + onStage("Source Stop confirmed. Requesting destination Start."); + const request = await prepare(destination, agent, "start"); + check(); + return send(request); + }; + return { check, prepare, read, send, sync, current, start, restart, move }; +} + +/** Subscribe first, then project history; live commands wait for complete + * initialization. Reconnect gets a new client epoch and never replays history. */ +export async function receiveLifecycle( + scope: DesktopScope, + active: () => boolean, + onError: (message: string) => void, + ipc = invoke, + relay = relayClient, +) { + let client: ReturnType; + let initialized: () => void = () => {}; + const ready = new Promise((resolve) => { + initialized = resolve; + }); + let chain = Promise.resolve(); + let pending = 0; + const close = await relay.subscribeLive( + { + kinds: [DESKTOP_LIFECYCLE, DESKTOP_STOP], + authors: [scope.owner], + limit: 0, + }, + (event) => { + if (!active()) return; + if (pending >= 16) { + onError("Desktop lifecycle receiver is busy; outcome is unconfirmed."); + return; + } + pending++; + chain = chain + .then(async () => { + await ready; + client.check(); + await ipc("observe_desktop_placement", { + ...scope, + events: [event], + reconcile: true, + }); + client.check(); + const result = await ipc( + event.kind === DESKTOP_STOP + ? "receive_desktop_stop" + : "receive_desktop_lifecycle", + { ...scope, event }, + ); + client.check(); + if (result) + await relay.publishEvent( + result, + "Result delivery unconfirmed", + "Result delivery failed", + client.check, + ); + }) + .catch(() => { + if (active()) + onError( + "Desktop lifecycle result is unconfirmed. No automatic operation retry.", + ); + }) + .finally(() => { + pending--; + }); + }, + (readiness) => { + if (active() && readiness !== "eose") + onError("Desktop lifecycle receiver is unavailable."); + }, + ); + client = lifecycleClient(scope, active, ipc, relay); + try { + await client.sync(); + client.check(); + initialized(); + } catch (error) { + close(); + // Release queued callbacks into a permanently invalidated client. + client = lifecycleClient(scope, () => false, ipc, relay); + initialized(); + throw error; + } + return close; +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 3839d01c7e5..2f94a65bf9b 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -115,6 +115,11 @@ export class RelayClient { getSessionEpoch() { return this.sessionEpoch; } + + /** Invalidates one-shot lifecycle coordinators on a transport interruption. */ + getConnectionGeneration() { + return this.connectionGeneration; + } disconnect() { const error = new Error("Relay disconnected for community switch."); From def4f237768fcdde83557ac76e5cb2e694bd732d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 17:46:29 -0400 Subject: [PATCH 21/51] feat(desktop): mount Start Restart and Move controls Signed-off-by: Logan Johnson --- .../ui/DesktopLifecycleControl.test.mjs | 133 +++++++++++ .../agents/ui/DesktopLifecycleControl.tsx | 226 ++++++++++++++++++ .../src/features/agents/ui/KnownDesktops.tsx | 18 +- 3 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs create mode 100644 desktop/src/features/agents/ui/DesktopLifecycleControl.tsx diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs new file mode 100644 index 00000000000..d6919521225 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { DesktopLifecycleControl } from "./DesktopLifecycleControl.tsx"; +import { relayClient } from "../../../shared/api/relayClient.ts"; + +test("mounted Start exposes unavailable provisioning and exact retry; Restart resolves source", async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + ["relay-agents"], + [ + { pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" }, + { pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" }, + ], + ); + const scope = { owner: "owner", community: "wss://one.example" }; + const originals = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + }; + const prepared = [], + sent = []; + let stop = "failed"; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + if (command === "observe_desktop_placement") return; + if (command === "read_desktop_placement") return ["source", "selection"]; + if ( + command === "prepare_desktop_lifecycle" || + command === "prepare_desktop_stop" + ) { + const request = { + id: `request-${prepared.length}`, + kind: command.endsWith("_stop") ? 50180 : 50182, + ...args, + }; + prepared.push(request); + return request; + } + if (command === "read_desktop_lifecycle_results") + return args.request.action === "status" + ? "running" + : "provisioning_unavailable"; + if (command === "read_desktop_stop_results") return stop; + throw Error(command); + }, + }; + relayClient.fetchEvents = async () => []; + relayClient.publishEvent = async (event, _timeout, _failure, check) => { + check(); + sent.push(event); + }; + const root = createRoot(document.getElementById("root")); + const click = (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + const select = (label, value) => + React.act(async () => { + const element = document.querySelector(`select[aria-label="${label}"]`); + element.value = value; + element.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + try { + await React.act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(DesktopLifecycleControl, { + scope, + desktops: [ + { id: "source", name: "Source" }, + { id: "destination", name: "Destination" }, + ], + }), + ), + ), + ); + assert.doesNotMatch(document.body.textContent, /Foreign agent/); + await select("Agent to place", "agent"); + await select("Destination Desktop", "destination"); + await click("Start on destination"); + assert.match( + document.body.textContent, + /keyless launch provisioning is unavailable/, + ); + assert.equal(prepared[0].desktop, "destination"); + await click("Retry same request"); + assert.equal(prepared.length, 1); + assert.equal(sent[0], sent[1]); + await click("Restart on current Desktop"); + const restart = prepared.at(-1); + assert.equal( + restart.desktop, + "source", + "destination picker must not redirect Restart", + ); + assert.equal(restart.action, "restart"); + assert.equal(restart.observed, prepared.at(-2).id); + await click("Move to destination"); + assert.match(document.body.textContent, /destination was not started/); + const count = prepared.length; + stop = "stopped"; + await React.act(async () => {}); + assert.equal(prepared.length, count, "late Stop cannot resume failed Move"); + assert.doesNotMatch(document.body.textContent, /Retry same request/); + } finally { + await React.act(async () => root.unmount()); + client.clear(); + relayClient.fetchEvents = originals.fetch; + relayClient.publishEvent = originals.publish; + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx new file mode 100644 index 00000000000..5baffa6f4cb --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -0,0 +1,226 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/shared/ui/button"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopRow, DesktopScope } from "../desktopList"; +import { + lifecycleClient, + receiveLifecycle, + type LifecycleOutcome, +} from "../desktopLifecycle"; +import { useRelayAgentsQuery } from "../hooks"; + +export function DesktopLifecycleReceiver({ + scope, +}: { + scope: DesktopScope | null; +}) { + const [error, setError] = useState(""); + const { owner, community } = scope ?? {}; + useEffect(() => { + if (!owner || !community) return; + let active = true; + let close: (() => void) | undefined; + setError(""); + void receiveLifecycle({ owner, community }, () => active, setError) + .then((fn) => { + if (active) close = fn; + else fn(); + }) + .catch(() => { + if (active) setError("Desktop lifecycle receiver is unavailable."); + }); + return () => { + active = false; + close?.(); + }; + }, [owner, community]); + return error ? ( +

+ {error} +

+ ) : null; +} +function message(outcome: LifecycleOutcome) { + switch (outcome) { + case "running": + return "Desktop confirmed a running local process. This does not prove model readiness."; + case "provisioning_unavailable": + return "Destination keyless launch provisioning is unavailable. No new process was started."; + case "stopped": + return "Desktop reports the agent stopped."; + case "failed": + return "Desktop rejected or failed the operation. No successful launch was confirmed."; + default: + return "Operation unconfirmed. A dispatched effect may still finish; no automatic retry will run."; + } +} +/** Start/Move choose destination; Restart has no host picker and resolves actual current state. */ +export function DesktopLifecycleControl({ + scope, + desktops, +}: { + scope: DesktopScope; + desktops: DesktopRow[]; +}) { + const agents = useRelayAgentsQuery(); + const [agent, setAgent] = useState(""); + const [destination, setDestination] = useState(""); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(""); + const [request, setRequest] = useState(null); + const active = useRef(true); + const generation = useRef(0); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + generation.current++; + }; + }, []); + const run = async (action: "start" | "restart" | "move" | "retry") => { + const token = ++generation.current; + const valid = () => active.current && generation.current === token; + const client = lifecycleClient(scope, valid); + setBusy(true); + setStatus("Checking authenticated Desktop state…"); + if (action !== "retry") setRequest(null); + try { + if (action === "move") { + const outcome = await client.move( + agent, + destination, + desktops.map((d) => d.id), + (stage) => { + if (valid()) setStatus(stage); + }, + ); + client.check(); + setStatus(message(outcome)); + } else { + const next = + action === "retry" + ? request + : action === "start" + ? await client.start(destination, agent) + : await client.restart( + agent, + desktops.map((d) => d.id), + ); + if (!next) throw new Error("No request to retry"); + client.check(); + setRequest(next); + setStatus("Request sent. Waiting for the Desktop’s actual result…"); + const outcome = await client.send(next); + client.check(); + setStatus(message(outcome)); + } + } catch (error) { + if (valid()) + setStatus( + error instanceof Error ? error.message : "Operation unconfirmed", + ); + } finally { + if (valid()) setBusy(false); + } + }; + const reset = () => { + setRequest(null); + setStatus(""); + }; + return ( +
+

Start, restart, or move an agent

+ + + +

+ Start may overlap with an agent still running elsewhere until that + Desktop reconnects. Move starts the destination only after source Stop + is confirmed. Nothing transfers files, configuration, or keys. +

+
+ + + {request && ( + + )} +
+ {status && ( +

+ {status} +

+ )} +
+ ); +} diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx index 45e7aa68eaa..5f4328b332c 100644 --- a/desktop/src/features/agents/ui/KnownDesktops.tsx +++ b/desktop/src/features/agents/ui/KnownDesktops.tsx @@ -1,4 +1,8 @@ -import { DesktopStopControl, DesktopStopReceiver } from "./DesktopStopControl"; +import { + DesktopLifecycleControl, + DesktopLifecycleReceiver, +} from "./DesktopLifecycleControl"; +import { DesktopStopControl } from "./DesktopStopControl"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -62,6 +66,7 @@ function useDesktopList() { /** Startup and Agents share the existing owner/community query cache. */ export function DesktopListStartup() { + const [epoch, setEpoch] = useState(0); const { refetch } = useDesktopList(); const { refetch: pulse } = useDesktopObservations(useDesktopScope()); const { refetch: report } = useDesktopCapabilities(useDesktopScope()); @@ -71,6 +76,7 @@ export function DesktopListStartup() { void report(); }, DESKTOP_PULSE_MS); const unsubscribe = relayClient.subscribeToReconnects(() => { + setEpoch((n) => n + 1); void refetch(); void pulse(); void report(); @@ -80,7 +86,8 @@ export function DesktopListStartup() { unsubscribe(); }; }, [refetch, pulse, report]); - return ; + const scope = useDesktopScope(); + return ; } export function KnownDesktops() { @@ -170,6 +177,13 @@ export function DesktopListView({

Partial list: showing up to 100 profiles.

)} {list && !list.rows.length && !error &&

No Desktop profiles found.

} + {scope && list && ( + + )}
    {list?.rows.map((row) => (
  • From c6f3e3eb2fcd0369e9bcb5adf9f245591697e13e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 17:54:00 -0400 Subject: [PATCH 22/51] fix(multiverse): retain superseded Stop outcomes for exact retry Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/desktop_stop.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs index 2c9623fb29a..d2361b92cf9 100644 --- a/desktop/src-tauri/src/commands/desktop_stop.rs +++ b/desktop/src-tauri/src/commands/desktop_stop.rs @@ -86,13 +86,14 @@ pub async fn receive_desktop_stop( if managed_agents::placement::desired(&conn, &target.agent)? .is_some_and(|(host, _)| host == desktop) { - return StopResult { + let result = StopResult { target, request: event.id.to_hex(), outcome: StopOutcome::Unknown, } - .sign(&scope.owner_keys) - .map(Some); + .sign(&scope.owner_keys)?; + remote_stop::save_result(&mut conn, &event.id.to_hex(), &result.as_json())?; + return Ok(Some(result)); } let owned = owned_local(&app, &state, &owner, &target.agent)?; remote_stop::receive( From 7d00439d05e636f2a1fa61049ede7e6a5db71f66 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 18:36:53 -0400 Subject: [PATCH 23/51] test(multiverse): scope Stop row and exercise mounted launch refusal Signed-off-by: Logan Johnson --- desktop/tests/e2e/desktop-stop.spec.ts | 69 +++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/desktop-stop.spec.ts b/desktop/tests/e2e/desktop-stop.spec.ts index a656aa45623..21df14154dc 100644 --- a/desktop/tests/e2e/desktop-stop.spec.ts +++ b/desktop/tests/e2e/desktop-stop.spec.ts @@ -31,12 +31,20 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as confirmed: boolean; prepared: number; sends: string[]; + lifecyclePrepared: number; + lifecycleSends: string[]; }; __TAURI_INTERNALS__: { invoke: (command: string, payload?: any, options?: any) => Promise; }; }; - w.__STOP_FIXTURE__ = { confirmed: false, prepared: 0, sends: [] }; + w.__STOP_FIXTURE__ = { + confirmed: false, + prepared: 0, + sends: [], + lifecyclePrepared: 0, + lifecycleSends: [], + }; const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); const now = Math.floor(Date.now() / 1000); const local = "11111111-1111-4111-8111-111111111111"; @@ -74,6 +82,19 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as reported: now, runtimes: [], })); + case "observe_desktop_placement": + return; + case "read_desktop_placement": + case "receive_desktop_lifecycle": + return null; + case "prepare_desktop_lifecycle": + w.__STOP_FIXTURE__.lifecyclePrepared++; + return sign(50182, [ + ["p", payload.owner], + ["d", payload.desktop], + ]); + case "read_desktop_lifecycle_results": + return "provisioning_unavailable"; case "prepare_desktop_stop": w.__STOP_FIXTURE__.prepared++; return sign(50180, [ @@ -88,6 +109,8 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as const wire = JSON.parse(payload.message.data); if (wire[0] === "EVENT" && wire[1]?.kind === 50180) w.__STOP_FIXTURE__.sends.push(JSON.stringify(wire[1])); + if (wire[0] === "EVENT" && wire[1]?.kind === 50182) + w.__STOP_FIXTURE__.lifecycleSends.push(JSON.stringify(wire[1])); break; } } @@ -98,7 +121,7 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as const desktops = page.getByRole("region", { name: "Known Desktops" }); await desktops.getByRole("button", { name: "Refresh", exact: true }).click(); await expect( - desktops.getByText("Lab Desktop", { exact: true }), + desktops.getByRole("listitem").getByText("Lab Desktop", { exact: true }), ).toBeVisible(); await desktops .getByRole("combobox", { name: "Agent to stop on Lab Desktop" }) @@ -160,4 +183,46 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as await desktops.screenshot({ path: "test-results/desktop-stop/04-confirmed.png", }); + + // The mounted lifecycle selector shares host labels with the Stop rows. + // IPC explicitly refuses launch; no native process is created by this fixture. + const controls = desktops.getByRole("region", { + name: "Agent placement controls", + }); + await controls + .getByRole("combobox", { name: "Agent to place" }) + .selectOption(agent); + await controls + .getByRole("combobox", { name: "Destination Desktop" }) + .selectOption("22222222-2222-4222-8222-222222222222"); + await controls + .getByRole("button", { name: "Start on destination", exact: true }) + .click(); + await expect(controls.getByRole("status")).toHaveText( + "Destination keyless launch provisioning is unavailable. No new process was started.", + ); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/05-launch-unavailable.png", + }); + await controls + .getByRole("button", { name: "Retry same request", exact: true }) + .click(); + await expect(controls.getByRole("status")).toHaveText( + "Destination keyless launch provisioning is unavailable. No new process was started.", + ); + const lifecycle = await page.evaluate( + () => + ( + window as typeof window & { + __STOP_FIXTURE__: { + lifecyclePrepared: number; + lifecycleSends: string[]; + }; + } + ).__STOP_FIXTURE__, + ); + expect(lifecycle.lifecyclePrepared).toBe(1); + expect(lifecycle.lifecycleSends).toHaveLength(2); + expect(lifecycle.lifecycleSends[1]).toBe(lifecycle.lifecycleSends[0]); }); From 943b62af7abde01b8e6fbe0c6d77ba377fbe2513 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 19:18:49 -0400 Subject: [PATCH 24/51] fix(desktop): keep lifecycle receiver failures out of shell layout Signed-off-by: Logan Johnson --- .../src/features/agents/desktopList.test.mjs | 3 + .../ui/DesktopLifecycleControl.test.mjs | 108 +++++++++++++++++- .../agents/ui/DesktopLifecycleControl.tsx | 26 +++-- desktop/src/testing/e2eBridge.ts | 6 + .../e2e/top-chrome-zoom-clearance.spec.ts | 17 ++- desktop/tests/helpers/bridge.ts | 2 + 6 files changed, 151 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs index 5e21adbb316..828964f432b 100644 --- a/desktop/src/features/agents/desktopList.test.mjs +++ b/desktop/src/features/agents/desktopList.test.mjs @@ -160,6 +160,8 @@ test("rendered list distinguishes current, partial, unavailable and empty withou }); test("mounted cache clears both scopes, fences late reads and retains rows on failure", async (t) => { + const originalRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); const { JSDOM } = await import("jsdom"); const dom = new JSDOM("
    ", { url: "https://desktop.test", @@ -375,6 +377,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa "reconnect producer unsubscribed on unmount", ); t.mock.timers.reset(); + globalThis.requestAnimationFrame = originalRaf; dom.window.close(); } }); diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs index d6919521225..f41068c6136 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -3,7 +3,11 @@ import test from "node:test"; import React from "react"; import { JSDOM } from "jsdom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { DesktopLifecycleControl } from "./DesktopLifecycleControl.tsx"; +import { + DesktopLifecycleControl, + DesktopLifecycleReceiver, +} from "./DesktopLifecycleControl.tsx"; +import { toast } from "sonner"; import { relayClient } from "../../../shared/api/relayClient.ts"; test("mounted Start exposes unavailable provisioning and exact retry; Restart resolves source", async () => { @@ -131,3 +135,105 @@ test("mounted Start exposes unavailable provisioning and exact retry; Restart re dom.window.close(); } }); + +test("receiver failure is a scope-owned notification, not pre-shell layout", async () => { + const originalRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + const dom = new JSDOM("
    ", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const originals = { + fetch: relayClient.fetchEvents, + subscribe: relayClient.subscribeLive, + }; + let readiness; + let closed = 0; + let rejectLate; + let delayed = false; + relayClient.fetchEvents = async () => { + if (delayed) + return new Promise((_, reject) => { + rejectLate = reject; + }); + return []; + }; + relayClient.subscribeLive = async (_filter, _event, onReadiness) => { + readiness = onReadiness; + return () => { + closed++; + }; + }; + window.__TAURI_INTERNALS__ = { + invoke: async () => { + throw new Error("fixture: storage unavailable"); + }, + }; + const root = createRoot(document.getElementById("root")); + const scope = { owner: "owner", community: "wss://one.example" }; + const warnings = () => + toast + .getToasts() + .filter((t) => String(t.title).startsWith("Desktop lifecycle")); + try { + await React.act(async () => + root.render(React.createElement(DesktopLifecycleReceiver, { scope })), + ); + assert.equal( + document.getElementById("root").childElementCount, + 0, + "startup must not render in-flow failure UI", + ); + assert.equal(warnings().length, 1); + assert.equal( + warnings()[0].title, + "Desktop lifecycle receiver is unavailable.", + ); + assert.equal(warnings()[0].duration, Infinity); + assert.equal(warnings()[0].closeButton, true); + readiness("closed"); + assert.equal( + warnings().length, + 1, + "repeated failures update one notification", + ); + await React.act(async () => + root.render( + React.createElement(DesktopLifecycleReceiver, { scope: null }), + ), + ); + assert.equal(warnings().length, 0, "leaving the scope removes its warning"); + readiness("closed"); + assert.equal( + warnings().length, + 0, + "retired receiver cannot notify another scope", + ); + delayed = true; + await React.act(async () => + root.render(React.createElement(DesktopLifecycleReceiver, { scope })), + ); + assert.equal(typeof rejectLate, "function"); + await React.act(async () => root.unmount()); + await React.act(async () => rejectLate(new Error("late failure"))); + assert.equal( + warnings().length, + 0, + "late startup rejection must not recreate the warning", + ); + assert.equal(closed, 2, "both failed subscriptions are released"); + } finally { + await React.act(async () => root.unmount()); + relayClient.fetchEvents = originals.fetch; + relayClient.subscribeLive = originals.subscribe; + for (const warning of warnings()) toast.dismiss(warning.id); + globalThis.requestAnimationFrame = originalRaf; + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx index 5baffa6f4cb..d05efee2511 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/shared/ui/button"; import type { RelayEvent } from "@/shared/api/types"; import type { DesktopRow, DesktopScope } from "../desktopList"; @@ -14,31 +15,38 @@ export function DesktopLifecycleReceiver({ }: { scope: DesktopScope | null; }) { - const [error, setError] = useState(""); const { owner, community } = scope ?? {}; useEffect(() => { if (!owner || !community) return; let active = true; let close: (() => void) | undefined; - setError(""); - void receiveLifecycle({ owner, community }, () => active, setError) + let notification: string | number | undefined; + const reportError = (message: string) => { + if (!active) return; + // Startup mounts before the app shell: failure UI must not participate + // in layout or displace the fixed macOS window controls. Keep one visible + // notification for this receiver, and retire it with its owner/scope. + notification = toast.error(message, { + id: notification, + duration: Infinity, + closeButton: true, + }); + }; + void receiveLifecycle({ owner, community }, () => active, reportError) .then((fn) => { if (active) close = fn; else fn(); }) .catch(() => { - if (active) setError("Desktop lifecycle receiver is unavailable."); + reportError("Desktop lifecycle receiver is unavailable."); }); return () => { active = false; close?.(); + if (notification !== undefined) toast.dismiss(notification); }; }, [owner, community]); - return error ? ( -

    - {error} -

    - ) : null; + return null; } function message(outcome: LifecycleOutcome) { switch (outcome) { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 89b1fb20aed..437916a1315 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -306,6 +306,7 @@ type E2eConfig = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + desktopLifecycleObservationError?: string; /** Result returned by the mocked `add_agent_to_huddle` command. */ addAgentToHuddleResult?: { ephemeral_added: boolean; @@ -13917,6 +13918,11 @@ export function maybeInstallE2eTauriMocks() { ], }; } + case "observe_desktop_placement": { + const error = activeConfig?.mock?.desktopLifecycleObservationError; + if (error) throw new Error(error); + return null; + } case "list_managed_agents": return handleListManagedAgents(activeConfig); case "get_agent_memory": diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts index d1615dbcc64..1d4d6795a76 100644 --- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts +++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { readFileSync } from "node:fs"; import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; type TauriConfig = { app: { @@ -109,8 +110,18 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => page, }) => { await spoofMacPlatform(page); - await installMockBridge(page); + await installMockBridge(page, { + desktopLifecycleObservationError: + "fixture: lifecycle storage unavailable", + }); await page.goto("/"); + // A failed global receiver must remain visible without entering the shell's + // layout flow. This also forces the error to settle before measuring chrome. + await expect( + page.getByText("Desktop lifecycle receiver is unavailable.", { + exact: true, + }), + ).toBeVisible(); // Lock the native and webview placements together: removing this explicit // Tauri inset or shifting the nav row regresses the macOS chrome alignment. @@ -133,6 +144,10 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => ); await expectNavButtonsFixedSize(page); await expectTopChromeFixedHeight(page); + await waitForAnimations(page); + await page.screenshot({ + path: "test-results/desktop-lifecycle/receiver-error-chrome.png", + }); }); test("nav buttons still clear the traffic lights when zoomed out", async ({ diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3f4ed69f4c..7c029f9b647 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -221,6 +221,8 @@ type MockBridgeOptions = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + /** Fail lifecycle history admission to exercise the global receiver warning. */ + desktopLifecycleObservationError?: string; /** Result returned by the mocked `add_agent_to_huddle` command. */ addAgentToHuddleResult?: { ephemeral_added: boolean; From cbb4091ef349927aee47d5387c574b0bdaf0714f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 22:37:49 -0400 Subject: [PATCH 25/51] fix(desktop): preserve launch community separately from runtime aliases Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/agents.rs | 17 ++-- .../src/managed_agents/remote_stop.rs | 93 ++++++++++++++++--- .../src-tauri/src/managed_agents/restore.rs | 16 ++-- .../src-tauri/src/managed_agents/runtime.rs | 9 +- .../src/managed_agents/runtime_commands.rs | 9 +- desktop/src-tauri/src/relay/scope.rs | 30 ++++++ 6 files changed, 139 insertions(+), 35 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index a3bc56882e2..df092d7dc44 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -176,14 +176,19 @@ async fn start_local_agent_with_preflight( replay_floor_unix: Option, ) -> Result { let launch_owner = workspace_owner_hex(state)?; - let launch_key = crate::managed_agents::ManagedAgentRuntimeKey::new( - pubkey, - &relay_ws_url_with_override(state), + // Runtime keys fold loopback aliases for process bookkeeping, not tenant + // identity. Preserve the workspace authority across the preflight await. + let launch_relay = crate::relay::bind_expected_relay_scope( + expected_relay_url, + relay_ws_url_with_override(state), )?; + let launch_key = + crate::managed_agents::ManagedAgentRuntimeKey::new(pubkey, launch_relay.as_str())?; let resume = if matches!(intent, LocalStartIntent::Explicit) { Some(crate::managed_agents::remote_stop::capture_resume( app, &launch_key, + launch_relay.as_str(), &launch_owner, )?) } else { @@ -236,10 +241,8 @@ async fn start_local_agent_with_preflight( // 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.or(Some(launch_key.relay_url.as_str())), - crate::relay::relay_ws_url_with_override(state), - )?; + let workspace_relay_url = + launch_relay.revalidate(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. diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs index 55d03649004..e6c93750389 100644 --- a/desktop/src-tauri/src/managed_agents/remote_stop.rs +++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs @@ -122,11 +122,12 @@ pub(crate) struct ResumeTicket { } pub(crate) fn capture_resume( - app: &AppHandle, + app: &AppHandle, key: &ManagedAgentRuntimeKey, + community: &str, owner: &str, ) -> Result { - let conn = connection(app, key, owner)?; + let conn = connection(app, community, owner)?; schema(&conn)?; let previous = conn .query_row( @@ -140,12 +141,11 @@ pub(crate) fn capture_resume( } fn connection( - app: &AppHandle, - key: &ManagedAgentRuntimeKey, + app: &AppHandle, + community: &str, owner: &str, ) -> Result { - let path = - scoped_retention_db_path(&super::managed_agents_base_dir(app)?, &key.relay_url, owner); + let path = scoped_retention_db_path(&super::managed_agents_base_dir(app)?, community, owner); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } @@ -155,8 +155,9 @@ fn connection( /// Every ordinary spawn passes here, including restore/config/reconcile. /// Caller holds the existing transition lock through child registration. pub(crate) fn check_launch( - app: &AppHandle, + app: &AppHandle, key: &ManagedAgentRuntimeKey, + community: &str, owner: Option<&str>, resume: Option<&ResumeTicket>, ) -> Result<(), String> { @@ -165,18 +166,18 @@ pub(crate) fn check_launch( if owner != Some(current_owner.as_str()) { return Err("Desktop launch owner changed".into()); } - let conn = connection(app, key, ¤t_owner)?; + let conn = connection(app, community, ¤t_owner)?; schema(&conn)?; let scope = super::retention::RetentionScope { db_path: scoped_retention_db_path( &super::managed_agents_base_dir(app)?, - &key.relay_url, + community, ¤t_owner, ), - relay_url: key.relay_url.clone(), + relay_url: community.to_owned(), owner_keys: state.signing_keys()?, }; - let mut local_conn = connection(app, key, ¤t_owner)?; + let mut local_conn = connection(app, community, ¤t_owner)?; let host = crate::commands::desktop_stop::local_id(&mut local_conn, &scope)?; if super::placement::blocked(&conn, &key.pubkey, &host)? && (resume.is_none() || super::placement::has_start(&conn, &key.pubkey)?) @@ -210,8 +211,9 @@ fn allow_launch(row: Option<&(String, bool)>, resume: Option<&ResumeTicket>) -> /// A failed spawn must not unblock config/restore. Commit only after the child /// has its ordinary receipt and tracked handle, still under the transition lock. pub(crate) fn finish_resume( - app: &AppHandle, + app: &AppHandle, key: &ManagedAgentRuntimeKey, + community: &str, owner: Option<&str>, ticket: Option<&ResumeTicket>, ) -> Result<(), String> { @@ -219,8 +221,8 @@ pub(crate) fn finish_resume( return Ok(()); } let owner = owner.ok_or("Desktop launch owner unavailable")?; - check_launch(app, key, Some(owner), ticket)?; - connection(app, key, owner)? + check_launch(app, key, community, Some(owner), ticket)?; + connection(app, community, owner)? .execute( "UPDATE desktop_stop_fence SET blocked=0 WHERE agent=?1", [&key.pubkey], @@ -250,6 +252,69 @@ mod tests { .sign_with_keys(keys) .unwrap() } + #[test] + fn launch_reads_receiver_fence_in_original_community_not_runtime_alias() { + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + context.config_mut().identifier = format!("buzz-test-{}", uuid::Uuid::new_v4()); + let state = crate::app_state::build_app_state(); + let keys = state.signing_keys().unwrap(); + let owner = keys.public_key().to_hex(); + let app = tauri::test::mock_builder() + .manage(state) + .build(context) + .unwrap(); + let root = app.path().app_data_dir().unwrap(); + let community = "ws://localhost:3037"; + let agent = Keys::generate().public_key().to_hex(); + let key = ManagedAgentRuntimeKey::new(&agent, community).unwrap(); + assert_ne!(key.relay_url, community); + let scope = super::super::retention::RetentionScope { + db_path: scoped_retention_db_path(&root.join("agents"), community, &owner), + relay_url: community.into(), + owner_keys: keys.clone(), + }; + let mut conn = connection(app.handle(), community, &owner).unwrap(); + let host = crate::commands::desktop_stop::local_id(&mut conn, &scope).unwrap(); + let target = StopTarget { + v: 1, + community: community.into(), + desktop: host, + agent, + }; + let stop = request(&keys, &target, 100); + receive( + &mut conn, + &stop, + &keys, + community, + &target.desktop, + true, + |_| Ok(()), + ) + .unwrap(); + assert!(check_launch(app.handle(), &key, community, Some(&owner), None).is_err()); + // The numeric community is a different authority, even though the + // process bookkeeping key historically folds the two spellings. + assert!(check_launch(app.handle(), &key, &key.relay_url, Some(&owner), None).is_ok()); + let resume = capture_resume(app.handle(), &key, community, &owner).unwrap(); + assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_ok()); + let newer = request(&keys, &target, 101); + receive( + &mut conn, + &newer, + &keys, + community, + &target.desktop, + true, + |_| Ok(()), + ) + .unwrap(); + assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_err()); + drop(conn); + drop(app); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() { let stopped = ("stop-a".to_owned(), true); diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 66aa95cba74..5e282c239d4 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -21,7 +21,11 @@ use tauri::Manager; enum SpawnOutcome { /// Boxed: the spawned process carries its full spawn-config snapshot, so an /// inline variant would make every `Skipped`/`Failed` outcome pay for it. - Spawned(super::ManagedAgentRuntimeKey, Box), + Spawned( + super::ManagedAgentRuntimeKey, + String, + Box, + ), Skipped, Failed(String), } @@ -341,7 +345,7 @@ pub async fn restore_managed_agents_on_launch( spawn_agent_child( app, record, - &key.relay_url, + &relay_url, true, owner_hex_ref, None, @@ -349,7 +353,7 @@ pub async fn restore_managed_agents_on_launch( ) }) { Ok(process) => { - SpawnOutcome::Spawned(key, Box::new(process)) + SpawnOutcome::Spawned(key, relay_url, Box::new(process)) } Err(error) => SpawnOutcome::Failed(error), } @@ -388,7 +392,7 @@ pub async fn restore_managed_agents_on_launch( // Skipped means a concurrent reconcile already owns a live child for // this pair; leave its runtime and record state untouched. SpawnOutcome::Skipped => continue, - SpawnOutcome::Spawned(key, mut process) => { + SpawnOutcome::Spawned(key, relay_url, mut process) => { let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { continue; }; @@ -416,11 +420,11 @@ pub async fn restore_managed_agents_on_launch( key.clone(), super::ManagedAgentPairRuntime::starting(*process), ); - // Carry the spawn key's relay into profile reconciliation so + // Carry the original launch community into profile reconciliation so // the background task queries/publishes on the relay this // spawn was actually keyed to — not whatever workspace is // active when the task eventually executes. - successfully_spawned.push((pubkey, key.relay_url.clone())); + successfully_spawned.push((pubkey, relay_url)); } SpawnOutcome::Failed(error) => { let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 8d97fd1fff1..8efe5826291 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -477,7 +477,7 @@ pub(crate) fn spawn_agent_child_with_broker( broker: Option<&super::broker_launch::BrokerSession>, ) -> Result { let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; - super::remote_stop::check_launch(app, &key, owner_hex, resume)?; + super::remote_stop::check_launch(app, &key, relay_url, owner_hex, resume)?; if let Some(session) = broker { session.validate(super::broker_launch::LaunchScope { owner: owner_hex.ok_or("Desktop owner unavailable")?, @@ -574,7 +574,8 @@ pub(crate) fn spawn_agent_child_with_broker( // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. - let effective_relay_url = runtime_key.relay_url.clone(); + // Process identity normalization must not select a different relay tenant. + let effective_relay_url = relay_url.to_owned(); // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) @@ -946,7 +947,7 @@ pub fn start_managed_agent_process( let mut process = spawn_agent_child( app, record, - &key.relay_url, + workspace_relay.as_str(), false, owner_hex, replay_floor_unix, @@ -973,7 +974,7 @@ pub fn start_managed_agent_process( record.last_error_code = None; runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); - super::remote_stop::finish_resume(app, &key, owner_hex, resume)?; + super::remote_stop::finish_resume(app, &key, workspace_relay.as_str(), owner_hex, resume)?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 665e3c5d426..ca7e7cc21a3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -322,6 +322,7 @@ pub(crate) fn start_pair_locked( Some(super::remote_stop::capture_resume( &app, &key, + &relay_url, owner.as_deref().ok_or("Desktop owner unavailable")?, )?) } else { @@ -330,7 +331,7 @@ pub(crate) fn start_pair_locked( let mut process = super::spawn_agent_child_with_broker( &app, record, - &key.relay_url, + &relay_url, lazy, owner.as_deref(), None, @@ -355,7 +356,7 @@ pub(crate) fn start_pair_locked( record.last_stopped_at = None; record.last_error = None; runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); - super::remote_stop::finish_resume(&app, &key, owner.as_deref(), resume.as_ref())?; + super::remote_stop::finish_resume(&app, &key, &relay_url, owner.as_deref(), resume.as_ref())?; let status = status_for(&app, record, &key, runtimes.get(&key), None); drop(runtimes); save_managed_agents(&app, &records)?; @@ -462,7 +463,7 @@ async fn probe_agent_relay_access( let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested_relay_url)?; let keys = nostr::Keys::parse(record.private_key_nsec.trim()) .map_err(|error| format!("invalid managed-agent key: {error}"))?; - let api_base = crate::relay::relay_http_base_url(&key.relay_url); + let api_base = crate::relay::relay_http_base_url(&requested_relay_url); tokio::time::timeout( std::time::Duration::from_secs(10), crate::relay::query_relay_at_with_keys( @@ -563,7 +564,7 @@ pub async fn reconcile_managed_agent_runtimes( Ok((record, key, requested)) => { match start_pair( record.pubkey.clone(), - key.relay_url.clone(), + requested.clone(), true, Some(&record.updated_at), false, diff --git a/desktop/src-tauri/src/relay/scope.rs b/desktop/src-tauri/src/relay/scope.rs index b9c73328aff..baaee4df8ab 100644 --- a/desktop/src-tauri/src/relay/scope.rs +++ b/desktop/src-tauri/src/relay/scope.rs @@ -41,6 +41,12 @@ impl ScopedWorkspaceRelay { pub fn as_str(&self) -> &str { &self.0 } + + /// Recheck a captured workspace after preflight without substituting a + /// runtime-normalized URL (which may alias distinct tenant authorities). + pub fn revalidate(self, workspace_relay_url: String) -> Result { + bind_expected_relay_scope(Some(self.as_str()), workspace_relay_url) + } } /// Validate a caller-captured relay scope against one workspace-relay read @@ -119,6 +125,30 @@ mod tests { bind_expected_signer, }; + #[test] + fn preflight_revalidation_preserves_localhost_authority_not_runtime_alias() { + let relay = "ws://localhost:3037"; + let captured = bind_expected_relay_scope(None, relay.into()).unwrap(); + let runtime = + crate::managed_agents::ManagedAgentRuntimeKey::new("a".repeat(64), captured.as_str()) + .unwrap(); + assert_eq!(runtime.relay_url, "ws://127.0.0.1:3037"); + assert_eq!(captured.revalidate(relay.into()).unwrap().as_str(), relay); + } + + #[test] + fn preflight_revalidation_rejects_switch_even_to_same_runtime_alias() { + for changed in ["ws://127.0.0.1:3037", "wss://other.example"] { + let captured = bind_expected_relay_scope(None, "ws://localhost:3037".into()).unwrap(); + assert!(captured.revalidate(changed.into()).is_err()); + } + assert!(bind_expected_relay_scope( + Some("ws://localhost:3037"), + "ws://127.0.0.1:3037".into(), + ) + .is_err()); + } + #[test] fn matching_scope_passes_across_ws_http_normalization() { assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-a.example") From a7d32b96c7ccaa82ab594bcc0123e9829321714f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 22:44:35 -0400 Subject: [PATCH 26/51] fix(desktop): diagnose and explicitly recover lifecycle receivers Signed-off-by: Logan Johnson --- desktop/src/features/agents/AGENTS.md | 7 +- .../features/agents/desktopLifecycle.test.mjs | 136 +++++++++++++ .../src/features/agents/desktopLifecycle.ts | 191 +++++++++++------- .../agents/desktopLifecycleDiagnostics.ts | 41 ++++ .../ui/DesktopLifecycleControl.test.mjs | 23 ++- .../agents/ui/DesktopLifecycleControl.tsx | 28 ++- .../e2e/top-chrome-zoom-clearance.spec.ts | 4 +- 7 files changed, 348 insertions(+), 82 deletions(-) create mode 100644 desktop/src/features/agents/desktopLifecycleDiagnostics.ts diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index be408c39b36..2589afefb4d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -312,7 +312,12 @@ with a TypeScript lookup table or an id comparison in a component. Known Desktops exposes an owner-private, explicitly selected agent+Desktop Stop, not inferred agent location. The app-scoped receiver subscribes live only; -reopening never replays commands. An explicit retry republishes the exact request; +reopening never replays commands. Receiver initialization reports a safe failure +stage without exposing raw transport/IPC exceptions. Its scope-owned notification +can explicitly retry the **receiver** with a fresh live-only subscription; that +must discard queued callbacks from the retired receiver, not retry an operation. +A readiness timeout is unconfirmed delivery, not a failed initialization; late +EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request; the relay redelivers stored Stop duplicates without repeating relay side effects. The receiver returns saved results or Unknown, never repeats a consumed Stop. Native owner-delegation and community checks diff --git a/desktop/src/features/agents/desktopLifecycle.test.mjs b/desktop/src/features/agents/desktopLifecycle.test.mjs index b15daccfcff..33de28d85ba 100644 --- a/desktop/src/features/agents/desktopLifecycle.test.mjs +++ b/desktop/src/features/agents/desktopLifecycle.test.mjs @@ -238,3 +238,139 @@ test("receiver projects history without executing it and invalidates live work o assert.equal(f.errors.length, 1); close(); }); + +for (const stage of [ + "subscription", + "history", + "projection", + "reconciliation", +]) { + test(`receiver reports safe ${stage} failure and never admits queued work`, async () => { + const f = fixture(); + const secret = "private key /home/private bearer secret"; + if (stage === "subscription") + f.relay.subscribeLive = async () => { + throw Error(secret); + }; + if (stage === "history") + f.relay.fetchEvents = async () => { + throw Error(secret); + }; + const ipc = async (command, args) => { + if ( + command === "observe_desktop_placement" && + ((stage === "projection" && !args.reconcile) || + (stage === "reconciliation" && args.reconcile)) + ) + throw Error(secret); + return f.ipc(command, args); + }; + const started = receiveLifecycle( + scope, + () => true, + (e) => f.errors.push(e), + ipc, + f.relay, + ); + f.deliver({ id: "queued", kind: 50182 }); + await assert.rejects(started, (error) => { + assert.match(error.message, new RegExp(`${stage}: request failed`)); + assert.doesNotMatch(error.message, /private|bearer|secret/); + return true; + }); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); + assert.deepEqual( + f.errors, + [], + "discarded callbacks cannot replace the startup diagnosis", + ); + }); +} + +test("explicit receiver recovery discards old queued work and projects history without replay", async () => { + const f = fixture(); + let rejectHistory; + f.relay.fetchEvents = () => + new Promise((_, reject) => { + rejectHistory = reject; + }); + const failed = receiveLifecycle( + scope, + () => true, + () => {}, + f.ipc, + f.relay, + ); + await tick(); + f.deliver({ id: "old-live", kind: 50182 }); + rejectHistory(Error("Timed out while loading channel history.")); + await assert.rejects(failed, /history: history timed out/); + f.relay.fetchEvents = async () => [{ id: "old-live", kind: 50182 }]; + const close = await receiveLifecycle( + scope, + () => true, + () => {}, + f.ipc, + f.relay, + ); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); + f.deliver({ id: "new-live", kind: 50182 }); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 1, + ); + f.deliver({ id: "closed-queue", kind: 50182 }); + close(); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 1, + ); +}); + +test("readiness timeout is distinct, late EOSE recovers, CLOSED retires old callbacks", async () => { + const f = fixture(); + let notify, deliver; + let closed = 0, + ready = 0; + f.relay.subscribeLive = async (_filter, event, onReady, timeout) => { + assert.equal(timeout, 5000); + deliver = event; + notify = onReady; + notify("timeout"); + return () => { + closed++; + }; + }; + const close = await receiveLifecycle( + scope, + () => true, + (e) => f.errors.push(e), + f.ipc, + f.relay, + () => ready++, + ); + assert.match(f.errors[0], /readiness timed out/); + assert.equal(ready, 0); + notify("eose"); + assert.equal(ready, 1); + notify("closed"); + deliver({ id: "late", kind: 50182 }); + await tick(); + assert.equal(closed, 1); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); + assert.match(f.errors.at(-1), /subscription closed/); + close(); +}); diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts index 7af772aa181..1fb321c67ac 100644 --- a/desktop/src/features/agents/desktopLifecycle.ts +++ b/desktop/src/features/agents/desktopLifecycle.ts @@ -9,6 +9,11 @@ import { sendStop, } from "./desktopStop"; +import { + LifecycleReceiverError, + receiverStep, +} from "./desktopLifecycleDiagnostics"; + export const DESKTOP_LIFECYCLE = 50182; export const DESKTOP_LIFECYCLE_RESULT = 50183; export type LifecycleAction = "start" | "restart" | "status"; @@ -99,27 +104,33 @@ export function lifecycleClient( let before_id: string | undefined; for (let page = 0; page < 64; page++) { check(); - const events = await relay.fetchEvents({ - kinds: [DESKTOP_STOP, DESKTOP_LIFECYCLE], - authors: [scope.owner], - limit: 256, - until, - before_id, - }); + const events = await receiverStep("history", () => + relay.fetchEvents({ + kinds: [DESKTOP_STOP, DESKTOP_LIFECYCLE], + authors: [scope.owner], + limit: 256, + until, + before_id, + }), + ); check(); // No effects while a partial page could still hide a dominating Start. - await ipc("observe_desktop_placement", { - ...scope, - events, - reconcile: false, - }); + await receiverStep("projection", () => + ipc("observe_desktop_placement", { + ...scope, + events, + reconcile: false, + }), + ); check(); if (events.length < 256) { - await ipc("observe_desktop_placement", { - ...scope, - events: [], - reconcile: true, - }); + await receiverStep("reconciliation", () => + ipc("observe_desktop_placement", { + ...scope, + events: [], + reconcile: true, + }), + ); check(); return; } @@ -250,14 +261,21 @@ export function lifecycleClient( } /** Subscribe first, then project history; live commands wait for complete - * initialization. Reconnect gets a new client epoch and never replays history. */ + * initialization. Explicit receiver retry starts a fresh live-only subscription, + * never retries an operation or executes historical commands. */ export async function receiveLifecycle( scope: DesktopScope, active: () => boolean, onError: (message: string) => void, ipc = invoke, relay = relayClient, + onReady: () => void = () => {}, ) { + let stopped = false; + let stopSubscription = () => {}; + let synced = false; + let subscriptionReady = false; + const valid = () => active() && !stopped; let client: ReturnType; let initialized: () => void = () => {}; const ready = new Promise((resolve) => { @@ -265,70 +283,99 @@ export async function receiveLifecycle( }); let chain = Promise.resolve(); let pending = 0; - const close = await relay.subscribeLive( - { - kinds: [DESKTOP_LIFECYCLE, DESKTOP_STOP], - authors: [scope.owner], - limit: 0, - }, - (event) => { - if (!active()) return; - if (pending >= 16) { - onError("Desktop lifecycle receiver is busy; outcome is unconfirmed."); - return; - } - pending++; - chain = chain - .then(async () => { - await ready; - client.check(); - await ipc("observe_desktop_placement", { - ...scope, - events: [event], - reconcile: true, - }); - client.check(); - const result = await ipc( - event.kind === DESKTOP_STOP - ? "receive_desktop_stop" - : "receive_desktop_lifecycle", - { ...scope, event }, + const unsubscribe = await receiverStep("subscription", () => + relay.subscribeLive( + { + kinds: [DESKTOP_LIFECYCLE, DESKTOP_STOP], + authors: [scope.owner], + limit: 0, + }, + (event) => { + if (!valid()) return; + if (pending >= 16) { + onError( + "Desktop lifecycle receiver is busy; outcome is unconfirmed.", ); - client.check(); - if (result) - await relay.publishEvent( - result, - "Result delivery unconfirmed", - "Result delivery failed", - client.check, + return; + } + pending++; + chain = chain + .then(async () => { + await ready; + if (!valid()) return; + client.check(); + await ipc("observe_desktop_placement", { + ...scope, + events: [event], + reconcile: true, + }); + client.check(); + const result = await ipc( + event.kind === DESKTOP_STOP + ? "receive_desktop_stop" + : "receive_desktop_lifecycle", + { ...scope, event }, ); - }) - .catch(() => { - if (active()) - onError( - "Desktop lifecycle result is unconfirmed. No automatic operation retry.", - ); - }) - .finally(() => { - pending--; - }); - }, - (readiness) => { - if (active() && readiness !== "eose") - onError("Desktop lifecycle receiver is unavailable."); - }, + client.check(); + if (result) + await relay.publishEvent( + result, + "Result delivery unconfirmed", + "Result delivery failed", + client.check, + ); + }) + .catch(() => { + if (valid()) + onError( + "Desktop lifecycle result is unconfirmed. No automatic operation retry.", + ); + }) + .finally(() => { + pending--; + }); + }, + (readiness) => { + if (!valid()) return; + subscriptionReady = readiness === "eose"; + if (readiness === "closed") { + stopped = true; + stopSubscription(); + onError( + "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.", + ); + } else if (readiness === "timeout") { + onError( + "Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.", + ); + } else if (synced) onReady(); + }, + 5000, + ), ); - client = lifecycleClient(scope, active, ipc, relay); + const close = () => { + stopped = true; + // Unsubscribe may fail on a dead socket; it must not revive this receiver + // or leave an unhandled promise. A retry always owns a new subscription. + void Promise.resolve() + .then(unsubscribe) + .catch(() => {}); + }; + stopSubscription = close; + client = lifecycleClient(scope, valid, ipc, relay); try { + if (stopped) throw new LifecycleReceiverError("subscription", "closed"); await client.sync(); client.check(); + synced = true; initialized(); + if (subscriptionReady) onReady(); } catch (error) { close(); - // Release queued callbacks into a permanently invalidated client. - client = lifecycleClient(scope, () => false, ipc, relay); initialized(); - throw error; + throw error instanceof LifecycleReceiverError + ? error + : new LifecycleReceiverError("initialization", error); } return close; } diff --git a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts new file mode 100644 index 00000000000..aebe01c41a9 --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts @@ -0,0 +1,41 @@ +/** Safe receiver diagnostics: raw IPC/transport errors can contain private data. */ +type Stage = + | "initialization" + | "subscription" + | "history" + | "projection" + | "reconciliation"; + +export class LifecycleReceiverError extends Error { + constructor(stage: Stage, error: unknown) { + const value = error instanceof Error ? error.message : error; + const reason = + value === "closed" + ? "subscription closed" + : value === "Desktop lifecycle scope changed" + ? "scope changed" + : value === "Relay session is terminal; cannot reconnect." + ? "relay session requires reconnection" + : value === "Timed out while loading channel history." + ? "history timed out" + : "request failed"; + super(`Desktop lifecycle receiver is unavailable (${stage}: ${reason}).`); + } +} + +export function receiverErrorMessage(error: unknown): string { + return error instanceof LifecycleReceiverError + ? error.message + : "Desktop lifecycle receiver is unavailable (initialization failed)."; +} + +export async function receiverStep( + stage: Stage, + action: () => Promise, +): Promise { + try { + return await action(); + } catch (error) { + throw new LifecycleReceiverError(stage, error); + } +} diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs index f41068c6136..3ac174ac536 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -157,6 +157,8 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy let closed = 0; let rejectLate; let delayed = false; + let storageAvailable = false; + let subscribed = 0; relayClient.fetchEvents = async () => { if (delayed) return new Promise((_, reject) => { @@ -165,13 +167,16 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy return []; }; relayClient.subscribeLive = async (_filter, _event, onReadiness) => { + subscribed++; readiness = onReadiness; + onReadiness("eose"); return () => { closed++; }; }; window.__TAURI_INTERNALS__ = { invoke: async () => { + if (storageAvailable) return; throw new Error("fixture: storage unavailable"); }, }; @@ -193,7 +198,7 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy assert.equal(warnings().length, 1); assert.equal( warnings()[0].title, - "Desktop lifecycle receiver is unavailable.", + "Desktop lifecycle receiver is unavailable (projection: request failed).", ); assert.equal(warnings()[0].duration, Infinity); assert.equal(warnings()[0].closeButton, true); @@ -203,6 +208,16 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy 1, "repeated failures update one notification", ); + const retryAction = warnings()[0].action; + assert.equal(retryAction.label, "Retry receiver"); + storageAvailable = true; + await React.act(async () => retryAction.onClick()); + assert.equal(subscribed, 2, "explicit recovery starts a new live receiver"); + assert.equal( + warnings().length, + 0, + "successful recovery removes its warning", + ); await React.act(async () => root.render( React.createElement(DesktopLifecycleReceiver, { scope: null }), @@ -227,7 +242,11 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy 0, "late startup rejection must not recreate the warning", ); - assert.equal(closed, 2, "both failed subscriptions are released"); + assert.equal( + closed, + 3, + "failed, recovered and retired subscriptions are released", + ); } finally { await React.act(async () => root.unmount()); relayClient.fetchEvents = originals.fetch; diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx index d05efee2511..0b6b63d67be 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -9,6 +9,7 @@ import { type LifecycleOutcome, } from "../desktopLifecycle"; import { useRelayAgentsQuery } from "../hooks"; +import { receiverErrorMessage } from "../desktopLifecycleDiagnostics"; export function DesktopLifecycleReceiver({ scope, @@ -16,6 +17,7 @@ export function DesktopLifecycleReceiver({ scope: DesktopScope | null; }) { const { owner, community } = scope ?? {}; + const [attempt, retry] = useState(0); useEffect(() => { if (!owner || !community) return; let active = true; @@ -30,22 +32,40 @@ export function DesktopLifecycleReceiver({ id: notification, duration: Infinity, closeButton: true, + action: { + label: "Retry receiver", + onClick: () => { + if (!active) return; + active = false; + close?.(); + retry(attempt + 1); + }, + }, }); }; - void receiveLifecycle({ owner, community }, () => active, reportError) + void receiveLifecycle( + { owner, community }, + () => active, + reportError, + undefined, + undefined, + () => { + if (active && notification !== undefined) toast.dismiss(notification); + }, + ) .then((fn) => { if (active) close = fn; else fn(); }) - .catch(() => { - reportError("Desktop lifecycle receiver is unavailable."); + .catch((error) => { + reportError(receiverErrorMessage(error)); }); return () => { active = false; close?.(); if (notification !== undefined) toast.dismiss(notification); }; - }, [owner, community]); + }, [owner, community, attempt]); return null; } function message(outcome: LifecycleOutcome) { diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts index 1d4d6795a76..97d6b69cb1f 100644 --- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts +++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts @@ -118,9 +118,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => // A failed global receiver must remain visible without entering the shell's // layout flow. This also forces the error to settle before measuring chrome. await expect( - page.getByText("Desktop lifecycle receiver is unavailable.", { - exact: true, - }), + page.getByText(/Desktop lifecycle receiver is unavailable/), ).toBeVisible(); // Lock the native and webview placements together: removing this explicit From 18258f91d5362e64d3413124290969e7bcd943a9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 23:03:24 -0400 Subject: [PATCH 27/51] fix(desktop): observe receiver closure after subscription readiness Signed-off-by: Logan Johnson --- .../features/agents/desktopLifecycle.test.mjs | 11 +- .../src/features/agents/desktopLifecycle.ts | 34 ++- .../ui/DesktopLifecycleControl.test.mjs | 12 +- desktop/src/shared/api/relayClientSession.ts | 25 +- desktop/src/shared/api/relayClientShared.ts | 15 + desktop/src/shared/api/relayClosedRecovery.ts | 17 +- .../api/relayLiveSubscriptionState.test.mjs | 268 ++++++++++++++++++ 7 files changed, 355 insertions(+), 27 deletions(-) create mode 100644 desktop/src/shared/api/relayLiveSubscriptionState.test.mjs diff --git a/desktop/src/features/agents/desktopLifecycle.test.mjs b/desktop/src/features/agents/desktopLifecycle.test.mjs index 33de28d85ba..2a0ff0f314d 100644 --- a/desktop/src/features/agents/desktopLifecycle.test.mjs +++ b/desktop/src/features/agents/desktopLifecycle.test.mjs @@ -342,10 +342,17 @@ test("readiness timeout is distinct, late EOSE recovers, CLOSED retires old call let notify, deliver; let closed = 0, ready = 0; - f.relay.subscribeLive = async (_filter, event, onReady, timeout) => { + f.relay.subscribeLive = async ( + _filter, + event, + _onReady, + timeout, + options, + ) => { assert.equal(timeout, 5000); deliver = event; - notify = onReady; + assert.equal(options.closedRecovery, "explicit"); + notify = options.onState; notify("timeout"); return () => { closed++; diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts index 1fb321c67ac..b81e4826052 100644 --- a/desktop/src/features/agents/desktopLifecycle.ts +++ b/desktop/src/features/agents/desktopLifecycle.ts @@ -335,22 +335,26 @@ export async function receiveLifecycle( pending--; }); }, - (readiness) => { - if (!valid()) return; - subscriptionReady = readiness === "eose"; - if (readiness === "closed") { - stopped = true; - stopSubscription(); - onError( - "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.", - ); - } else if (readiness === "timeout") { - onError( - "Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.", - ); - } else if (synced) onReady(); - }, + undefined, 5000, + { + closedRecovery: "explicit", + onState: (readiness) => { + if (!valid()) return; + subscriptionReady = readiness === "eose"; + if (readiness === "closed") { + stopped = true; + stopSubscription(); + onError( + "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.", + ); + } else if (readiness === "timeout") { + onError( + "Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.", + ); + } else if (synced) onReady(); + }, + }, ), ); const close = () => { diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs index 3ac174ac536..35e52f7aab0 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -166,10 +166,16 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy }); return []; }; - relayClient.subscribeLive = async (_filter, _event, onReadiness) => { + relayClient.subscribeLive = async ( + _filter, + _event, + _onReadiness, + _timeout, + options, + ) => { subscribed++; - readiness = onReadiness; - onReadiness("eose"); + readiness = options.onState; + readiness("eose"); return () => { closed++; }; diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 2f94a65bf9b..a7c0bded02d 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -16,6 +16,7 @@ import { getTextPayload, toRelayFrames, type ConnectionState, + type LiveSubscriptionOptions, type LiveSubscriptionReadiness, type PendingEvent, type RelaySubscription, @@ -419,8 +420,15 @@ export class RelayClient { onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, readinessTimeoutMs?: number, + options?: LiveSubscriptionOptions, ) { - return this.subscribe(filter, onEvent, onReady, readinessTimeoutMs); + return this.subscribe( + filter, + onEvent, + onReady, + readinessTimeoutMs, + options, + ); } async subscribeToChannelMentionEvents( channelId: string, @@ -609,6 +617,7 @@ export class RelayClient { onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, readinessTimeoutMs = 250, + options: LiveSubscriptionOptions = {}, ) { await this.ensureConnected(); @@ -621,16 +630,18 @@ export class RelayClient { resolve(); }; }); - const fallbackTimeout = window.setTimeout( - () => resolveReady("timeout"), - readinessTimeoutMs, - ); + const fallbackTimeout = window.setTimeout(() => { + options.onState?.("timeout"); + resolveReady("timeout"); + }, readinessTimeoutMs); this.subscriptions.set(subId, { mode: "live", filter, onEvent, resolveReady, + onState: options.onState, + closedRecovery: options.closedRecovery ?? "shared", }); try { @@ -1066,8 +1077,12 @@ export class RelayClient { continue; } subscription.resolveReady?.("closed"); + subscription.onState?.("closed"); subscription.resolveReady = undefined; clearClosedRetry(subscription); + if (subscription.closedRecovery === "explicit") { + this.subscriptions.delete(subId); + } } for (const [eventId, pendingEvent] of this.pendingEvents) { window.clearTimeout(pendingEvent.timeout); diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 8bd6379d7a9..925c74b573d 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -70,11 +70,26 @@ type FirstEventSubscription = { export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout"; +/** + * Optional lifecycle policy for a live subscription. + * + * Most subscriptions keep the shared reconnect/CLOSED recovery behavior. A + * command receiver can instead request explicit recovery: every CLOSED retires + * that subscription and `onState` remains observable after initial EOSE so the + * owning UI can offer a deliberate fresh subscription. + */ +export type LiveSubscriptionOptions = { + onState?: (state: LiveSubscriptionReadiness) => void; + closedRecovery?: "shared" | "explicit"; +}; + type LiveSubscription = { mode: "live"; filter: RelaySubscriptionFilter; onEvent: (event: RelayEvent) => void; resolveReady?: (readiness: LiveSubscriptionReadiness) => void; + onState?: (state: LiveSubscriptionReadiness) => void; + closedRecovery: "shared" | "explicit"; lastSeenCreatedAt?: number; /** * Lower bound of a reconnect backfill window that has not yet completed. diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index 9d30a233e7b..8f1a68287d2 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -124,10 +124,24 @@ function recoverLiveSubscriptionFromClosed({ sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise; }) { subscription.resolveReady?.("closed"); + subscription.onState?.("closed"); subscription.resolveReady = undefined; const closedClass = classifyRelayClosed(message); + if (closedClass === "rate-limited") { + const hintSeconds = parseRateLimitHint(message); + activateRateLimit(hintSeconds); + } + + if (subscription.closedRecovery === "explicit") { + // Command receivers must not survive CLOSED into shared re-subscription. + // Their owner presents an explicit fresh-receiver action instead. + clearClosedRetry(subscription); + subscriptions.delete(subId); + return; + } + if (closedClass === "terminal") { // Auth/access/filter failure — permanently remove the subscription so it // doesn't silently loop. @@ -146,9 +160,7 @@ function recoverLiveSubscriptionFromClosed({ let delayMs = backoffMs; if (closedClass === "rate-limited") { - // Activate the gate so concurrent operations back off too. const hintSeconds = parseRateLimitHint(message); - activateRateLimit(hintSeconds); // Use the gate's actual remaining time so a shorter hint arriving under a // longer active gate does not schedule a premature retry that just gets // another CLOSED. The fallback covers the gate-inactive edge case @@ -259,6 +271,7 @@ export function handleSubscriptionEose({ if (generation !== undefined) markReconnectLiveEose(subscription, generation); subscription.resolveReady?.("eose"); + subscription.onState?.("eose"); subscription.resolveReady = undefined; subscription.closedRetryAttempt = 0; clearClosedRetry(subscription); diff --git a/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs new file mode 100644 index 00000000000..587bfae1f28 --- /dev/null +++ b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +let fakeNow = 0; +let nextTimerId = 1; +const pendingTimers = new Map(); +const sentFrames = []; + +globalThis.window = { + setTimeout: (fn, ms) => { + const id = nextTimerId++; + pendingTimers.set(id, { fn, fireAt: fakeNow + ms }); + return id; + }, + clearTimeout: (id) => pendingTimers.delete(id), + __TAURI_INTERNALS__: { + invoke: async (command, args) => { + if (command === "plugin:websocket|send") sentFrames.push(args); + }, + }, +}; +Date.now = () => fakeNow; + +const { RelayClient } = await import("./relayClientSession.ts"); +const { receiveLifecycle } = await import("@/features/agents/desktopLifecycle"); +const { resetRateLimitGate } = await import("./relayRateLimitGate.ts"); + +function resetHarness() { + fakeNow = 0; + nextTimerId = 1; + pendingTimers.clear(); + sentFrames.length = 0; + resetRateLimitGate(); +} + +function connectedClient() { + const client = new RelayClient(); + client.wsId = 7; + return client; +} + +function sentProtocolFrames(type) { + return sentFrames + .map(({ message }) => JSON.parse(message.data)) + .filter((frame) => frame[0] === type); +} + +async function flushUntil(predicate, attempts = 40) { + for (let attempt = 0; attempt < attempts; attempt++) { + if (predicate()) return; + await Promise.resolve(); + } + assert.fail("condition did not become true before the microtask limit"); +} + +async function flushMicrotasks(attempts = 10) { + for (let attempt = 0; attempt < attempts; attempt++) await Promise.resolve(); +} + +function tickTo(time) { + fakeNow = time; + for (;;) { + const due = [...pendingTimers.entries()].filter( + ([, timer]) => timer.fireAt <= fakeNow, + ); + if (!due.length) return; + for (const [id, timer] of due) { + if (!pendingTimers.delete(id)) continue; + timer.fn(); + } + } +} + +function deliver(client, frame) { + return client.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + client.connectionGeneration, + ); +} + +async function openLive(client, options, onEvent = () => {}, onReady) { + const opened = client.subscribeLive( + { kinds: [50182, 50180], authors: ["owner"], limit: 0 }, + onEvent, + onReady, + 5000, + options, + ); + await flushUntil(() => sentProtocolFrames("REQ").length > 0); + const subId = sentProtocolFrames("REQ").at(-1)[1]; + return { opened, subId }; +} + +test("persistent state reports timeout, late EOSE, then CLOSED through RelayClient", async () => { + resetHarness(); + const client = connectedClient(); + const states = []; + const readiness = []; + const { opened, subId } = await openLive( + client, + { closedRecovery: "explicit", onState: (state) => states.push(state) }, + () => {}, + (state) => readiness.push(state), + ); + + tickTo(5000); + const close = await opened; + assert.deepEqual(states, ["timeout"]); + assert.deepEqual(readiness, ["timeout"]); + + await deliver(client, ["EOSE", subId]); + await deliver(client, ["CLOSED", subId, "restricted: access revoked"]); + + assert.deepEqual(states, ["timeout", "eose", "closed"]); + assert.deepEqual(readiness, ["timeout", "eose"]); + assert.equal(client.subscriptions.has(subId), false); + await close(); +}); + +for (const [label, message] of [ + ["terminal", "restricted: access revoked"], + ["retryable", "error: storage temporarily unavailable"], +]) { + test(`explicit recovery retires an EOSE-ready ${label} CLOSED without re-REQ`, async () => { + resetHarness(); + const client = connectedClient(); + const states = []; + const { opened, subId } = await openLive(client, { + closedRecovery: "explicit", + onState: (state) => states.push(state), + }); + await deliver(client, ["EOSE", subId]); + const close = await opened; + + await deliver(client, ["CLOSED", subId, message]); + tickTo(60_000); + await Promise.resolve(); + + assert.deepEqual(states, ["eose", "closed"]); + assert.equal(client.subscriptions.has(subId), false); + assert.equal(sentProtocolFrames("REQ").length, 1); + await close(); + }); +} + +test("connection reset reports CLOSED and retires only explicit-recovery subscriptions", async () => { + resetHarness(); + const client = connectedClient(); + const explicitStates = []; + const explicit = await openLive(client, { + closedRecovery: "explicit", + onState: (state) => explicitStates.push(state), + }); + await deliver(client, ["EOSE", explicit.subId]); + const closeExplicit = await explicit.opened; + + const ordinary = await openLive(client); + await deliver(client, ["EOSE", ordinary.subId]); + const closeOrdinary = await ordinary.opened; + + client.resetConnection(new Error("fixture connection reset")); + + assert.deepEqual(explicitStates, ["eose", "closed"]); + assert.equal(client.subscriptions.has(explicit.subId), false); + assert.equal( + client.subscriptions.has(ordinary.subId), + true, + "default live subscribers retain shared reconnect recovery", + ); + + await closeExplicit(); + await closeOrdinary(); + client.disconnect(); +}); + +test("default live subscribers retain shared retry after retryable CLOSED", async () => { + resetHarness(); + const client = connectedClient(); + const ordinary = await openLive(client); + await deliver(client, ["EOSE", ordinary.subId]); + const close = await ordinary.opened; + + await deliver(client, [ + "CLOSED", + ordinary.subId, + "error: storage temporarily unavailable", + ]); + assert.equal(client.subscriptions.has(ordinary.subId), true); + + tickTo(1000); + await flushUntil(() => sentProtocolFrames("REQ").length === 2); + assert.equal(client.subscriptions.has(ordinary.subId), true); + + await close(); +}); + +test("real explicit CLOSED fences queued lifecycle work and a deliberate retry is fresh", async () => { + resetHarness(); + const client = connectedClient(); + let resolveHistory; + client.fetchEvents = () => + new Promise((resolve) => { + resolveHistory = resolve; + }); + const ipcCalls = []; + const ipc = async (command) => { + ipcCalls.push(command); + return null; + }; + const errors = []; + const receiving = receiveLifecycle( + { owner: "owner", community: "wss://one.example" }, + () => true, + (error) => errors.push(error), + ipc, + client, + ); + + await flushUntil(() => sentProtocolFrames("REQ").length === 1); + const retiredSubId = sentProtocolFrames("REQ")[0][1]; + await deliver(client, ["EOSE", retiredSubId]); + await flushUntil(() => resolveHistory !== undefined); + await deliver(client, [ + "EVENT", + retiredSubId, + { id: "queued-old", kind: 50182, created_at: 1 }, + ]); + tickTo(20); + await deliver(client, ["CLOSED", retiredSubId, "restricted: access revoked"]); + resolveHistory([]); + + await assert.rejects(receiving, /receiver is unavailable/); + await flushMicrotasks(); + assert.equal( + ipcCalls.includes("receive_desktop_lifecycle"), + false, + "queued work from the retired receiver must not execute", + ); + assert.match(errors.at(-1), /subscription closed/); + + client.fetchEvents = async () => []; + const retried = receiveLifecycle( + { owner: "owner", community: "wss://one.example" }, + () => true, + (error) => errors.push(error), + ipc, + client, + ); + await flushUntil(() => sentProtocolFrames("REQ").length === 2); + const freshSubId = sentProtocolFrames("REQ")[1][1]; + assert.notEqual(freshSubId, retiredSubId); + await deliver(client, ["EOSE", freshSubId]); + const close = await retried; + + await deliver(client, [ + "EVENT", + freshSubId, + { id: "new-live", kind: 50182, created_at: 2 }, + ]); + tickTo(40); + await flushUntil(() => ipcCalls.includes("receive_desktop_lifecycle")); + assert.equal( + ipcCalls.filter((command) => command === "receive_desktop_lifecycle") + .length, + 1, + ); + await close(); +}); From 6bea46f78c5d5722e9162641d310bdd80203793a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 23:40:53 -0400 Subject: [PATCH 28/51] fix(desktop): bound lifecycle receiver recovery without command replay Signed-off-by: Logan Johnson --- desktop/src/features/agents/AGENTS.md | 10 +- .../src/features/agents/desktopLifecycle.ts | 18 +- .../agents/desktopLifecycleReceiver.test.mjs | 443 ++++++++++++++++++ .../agents/desktopLifecycleReceiver.ts | 142 ++++++ .../ui/DesktopLifecycleControl.test.mjs | 19 +- .../agents/ui/DesktopLifecycleControl.tsx | 34 +- desktop/src/shared/api/relayClientSession.ts | 25 +- desktop/src/shared/api/relayClientShared.ts | 22 +- desktop/src/shared/api/relayClosedRecovery.ts | 11 +- .../api/relayLiveSubscriptionState.test.mjs | 132 +++++- .../e2e/top-chrome-zoom-clearance.spec.ts | 5 +- 11 files changed, 800 insertions(+), 61 deletions(-) create mode 100644 desktop/src/features/agents/desktopLifecycleReceiver.test.mjs create mode 100644 desktop/src/features/agents/desktopLifecycleReceiver.ts diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2589afefb4d..e48401b4765 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -313,9 +313,13 @@ with a TypeScript lookup table or an id comparison in a component. Known Desktops exposes an owner-private, explicitly selected agent+Desktop Stop, not inferred agent location. The app-scoped receiver subscribes live only; reopening never replays commands. Receiver initialization reports a safe failure -stage without exposing raw transport/IPC exceptions. Its scope-owned notification -can explicitly retry the **receiver** with a fresh live-only subscription; that -must discard queued callbacks from the retired receiver, not retry an operation. +stage without exposing raw transport/IPC exceptions. Transient initialization +failures and transient CLOSED states recover through a bounded receiver-owner +budget; each attempt uses a fresh live-only subscription and repeats +projection-only sync before admission. Terminal closure or exhausted recovery +stays in the scope-owned notification, whose deliberate retry resets the receiver +budget. Recovery must discard queued callbacks from the retired receiver, not +retry an operation, and must respect the relay rate-limit gate. A readiness timeout is unconfirmed delivery, not a failed initialization; late EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request; the relay redelivers stored Stop duplicates without repeating relay side effects. diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts index b81e4826052..c5f00cafe2b 100644 --- a/desktop/src/features/agents/desktopLifecycle.ts +++ b/desktop/src/features/agents/desktopLifecycle.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { relayClient } from "@/shared/api/relayClient"; +import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientShared"; import type { RelayEvent } from "@/shared/api/types"; import type { DesktopScope } from "./desktopList"; import { @@ -270,8 +271,10 @@ export async function receiveLifecycle( ipc = invoke, relay = relayClient, onReady: () => void = () => {}, + onClosed?: (recovery: LiveSubscriptionClosedRecovery) => void, ) { let stopped = false; + let released = false; let stopSubscription = () => {}; let synced = false; let subscriptionReady = false; @@ -339,15 +342,20 @@ export async function receiveLifecycle( 5000, { closedRecovery: "explicit", - onState: (readiness) => { + onState: (readiness, closed) => { if (!valid()) return; subscriptionReady = readiness === "eose"; if (readiness === "closed") { stopped = true; stopSubscription(); - onError( - "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.", - ); + if (onClosed) + onClosed( + closed ?? { classification: "terminal", retryAfterMs: 0 }, + ); + else + onError( + "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.", + ); } else if (readiness === "timeout") { onError( "Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.", @@ -358,6 +366,8 @@ export async function receiveLifecycle( ), ); const close = () => { + if (released) return; + released = true; stopped = true; // Unsubscribe may fail on a dead socket; it must not revive this receiver // or leave an unhandled promise. A retry always owns a new subscription. diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs new file mode 100644 index 00000000000..70b96890052 --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs @@ -0,0 +1,443 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { receiveLifecycle } from "./desktopLifecycle.ts"; +import { + ownLifecycleReceiver, + RECEIVER_RECOVERY_DELAYS_MS, +} from "./desktopLifecycleReceiver.ts"; + +const scope = { owner: "owner", community: "wss://one.example" }; + +async function flushUntil(predicate, attempts = 40) { + for (let attempt = 0; attempt < attempts; attempt++) { + if (predicate()) return; + await Promise.resolve(); + } + assert.fail("condition did not become true before the microtask limit"); +} + +function timers() { + let nextId = 1; + const pending = new Map(); + return { + setTimer(callback, delayMs) { + const id = nextId++; + pending.set(id, { callback, delayMs }); + return id; + }, + clearTimer(id) { + pending.delete(id); + }, + fireNext() { + const entry = pending.entries().next().value; + assert.ok(entry, "expected a pending recovery timer"); + pending.delete(entry[0]); + entry[1].callback(); + }, + pending, + }; +} + +test("first subscribe failure recovers without a reconnect callback and syncs before admission", async () => { + const clock = timers(); + let subscribeCalls = 0; + let liveEvent; + let activeSubscriptions = 0; + let closed = 0; + let ready = 0; + const calls = []; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async (filter, onEvent, _onReady, _timeout, options) => { + assert.deepEqual(filter, { + kinds: [50182, 50180], + authors: [scope.owner], + limit: 0, + }); + subscribeCalls++; + if (subscribeCalls === 1) + throw new Error("fixture first connection rejected"); + activeSubscriptions++; + liveEvent = onEvent; + onEvent({ id: "during-sync", kind: 50182, created_at: 1 }); + options.onState("eose"); + return () => { + activeSubscriptions--; + closed++; + }; + }, + fetchEvents: async () => { + calls.push("history"); + return []; + }, + publishEvent: async () => {}, + }; + const ipc = async (command, args) => { + if (command === "observe_desktop_placement") { + calls.push(args.reconcile ? `projection:${args.events.length}` : "page"); + return; + } + if (command === "receive_desktop_lifecycle") { + calls.push("admission"); + return null; + } + throw new Error(command); + }; + const errors = []; + const stop = ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => ready++, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + ipc, + relay, + onReady, + onClosed, + ), + }, + ); + + await flushUntil(() => clock.pending.size === 1); + assert.equal(subscribeCalls, 1); + assert.deepEqual(errors, []); + assert.equal(clock.pending.values().next().value.delayMs, 1_000); + + clock.fireNext(); + await flushUntil(() => calls.includes("admission")); + assert.equal(subscribeCalls, 2); + assert.equal(activeSubscriptions, 1); + assert.equal(ready, 1); + assert.deepEqual(calls, [ + "history", + "page", + "projection:0", + "projection:1", + "admission", + ]); + + liveEvent({ id: "ordinary-live", kind: 50182, created_at: 2 }); + await flushUntil( + () => calls.filter((call) => call === "admission").length === 2, + ); + stop(); + await flushUntil(() => activeSubscriptions === 0); + assert.equal(activeSubscriptions, 0); + assert.equal(closed, 1); +}); + +test("transient CLOSED before readiness retires and replaces the whole receiver", async () => { + const clock = timers(); + let subscribeCalls = 0; + let closeCount = 0; + let ready = 0; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async (_filter, _onEvent, _onReady, _timeout, options) => { + subscribeCalls++; + if (subscribeCalls === 1) + options.onState("closed", { + classification: "retryable", + retryAfterMs: 0, + }); + else options.onState("eose"); + return () => closeCount++; + }, + fetchEvents: async () => [], + publishEvent: async () => {}, + }; + const stop = ownLifecycleReceiver( + scope, + () => {}, + () => ready++, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async () => {}, + relay, + onReady, + onClosed, + ), + }, + ); + + await flushUntil(() => clock.pending.size === 1); + await flushUntil(() => closeCount === 1); + assert.equal(subscribeCalls, 1); + assert.equal(closeCount, 1); + clock.fireNext(); + await flushUntil(() => ready === 1); + assert.equal(subscribeCalls, 2); + stop(); + await flushUntil(() => closeCount === 2); +}); + +test("scope cancellation clears recovery timers and closes a late subscription", async () => { + const timerClock = timers(); + let starts = 0; + const stopTimerOwner = ownLifecycleReceiver( + scope, + () => {}, + () => {}, + { + ...timerClock, + waitForRateLimit: async () => {}, + startReceiver: async () => { + starts++; + throw new Error("transient"); + }, + }, + ); + await flushUntil(() => timerClock.pending.size === 1); + stopTimerOwner(); + assert.equal(timerClock.pending.size, 0); + assert.equal(starts, 1); + + let finishSubscribe; + let lateCloseCount = 0; + let historyCalls = 0; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: () => + new Promise((resolve) => { + finishSubscribe = () => resolve(() => lateCloseCount++); + }), + fetchEvents: async () => { + historyCalls++; + return []; + }, + publishEvent: async () => {}, + }; + const stopLateOwner = ownLifecycleReceiver( + scope, + () => {}, + () => {}, + { + ...timers(), + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async () => {}, + relay, + onReady, + onClosed, + ), + }, + ); + await flushUntil(() => typeof finishSubscribe === "function"); + stopLateOwner(); + finishSubscribe(); + await flushUntil(() => lateCloseCount === 1); + assert.equal(historyCalls, 0, "cancelled receiver must not begin sync"); +}); + +test("scope cancellation during sync fences reconciliation, admission, readiness, and errors", async () => { + let resolveHistory; + let deliver; + let closeCount = 0; + let ready = 0; + const errors = []; + const calls = []; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async (_filter, onEvent, _onReady, _timeout, options) => { + deliver = onEvent; + options.onState("eose"); + return () => closeCount++; + }, + fetchEvents: () => + new Promise((resolve) => { + resolveHistory = resolve; + }), + publishEvent: async () => {}, + }; + const stop = ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => ready++, + { + ...timers(), + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async (command) => { + calls.push(command); + return null; + }, + relay, + onReady, + onClosed, + ), + }, + ); + await flushUntil(() => resolveHistory !== undefined); + deliver({ id: "queued", kind: 50182, created_at: 1 }); + stop(); + resolveHistory([]); + await flushUntil(() => closeCount === 1); + + assert.deepEqual(calls, []); + assert.deepEqual(errors, []); + assert.equal(ready, 0); +}); + +test("initializer recovery exhausts the bounded budget and preserves its safe terminal outcome", async () => { + const clock = timers(); + let starts = 0; + const errors = []; + ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => {}, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: async () => { + starts++; + throw new Error("raw private initializer detail"); + }, + }, + ); + + for ( + let attempt = 0; + attempt < RECEIVER_RECOVERY_DELAYS_MS.length; + attempt++ + ) { + await flushUntil(() => clock.pending.size === 1); + assert.equal( + clock.pending.values().next().value.delayMs, + RECEIVER_RECOVERY_DELAYS_MS[attempt], + ); + clock.fireNext(); + } + await flushUntil(() => errors.length === 1); + assert.equal(starts, 4); + assert.equal(clock.pending.size, 0); + assert.equal( + errors[0], + "Desktop lifecycle receiver is unavailable (initialization failed).", + ); + assert.doesNotMatch(errors[0], /private|detail/); +}); + +test("transient CLOSED recovery is bounded across successful receivers; terminal stays manual", async () => { + const clock = timers(); + const closures = []; + let starts = 0; + let closes = 0; + const errors = []; + const stop = ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => {}, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: async (_scope, _active, _onError, onReady, onClosed) => { + starts++; + onReady(); + closures.push(onClosed); + return () => closes++; + }, + }, + ); + await flushUntil(() => closures.length === 1); + + for ( + let attempt = 0; + attempt < RECEIVER_RECOVERY_DELAYS_MS.length; + attempt++ + ) { + closures.at(-1)({ classification: "retryable", retryAfterMs: 0 }); + assert.equal( + clock.pending.values().next().value.delayMs, + RECEIVER_RECOVERY_DELAYS_MS[attempt], + ); + clock.fireNext(); + await flushUntil(() => closures.length === attempt + 2); + } + closures.at(-1)({ classification: "retryable", retryAfterMs: 0 }); + assert.equal(clock.pending.size, 0); + assert.equal(starts, 4); + assert.equal(closes, 4); + assert.equal(errors.length, 1); + assert.match(errors[0], /Retry the receiver/); + stop(); + + const terminalClock = timers(); + let terminalClosed; + const terminalErrors = []; + ownLifecycleReceiver( + scope, + (error) => terminalErrors.push(error), + () => {}, + { + ...terminalClock, + startReceiver: async (_scope, _active, _onError, _onReady, onClosed) => { + terminalClosed = onClosed; + return () => {}; + }, + }, + ); + await flushUntil(() => terminalClosed !== undefined); + terminalClosed({ classification: "terminal", retryAfterMs: 0 }); + assert.equal(terminalClock.pending.size, 0); + assert.equal(terminalErrors.length, 1); +}); + +test("rate-limited recovery honors both the delay and shared gate, and cancellation fences it", async () => { + const clock = timers(); + let releaseGate; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const closures = []; + let starts = 0; + const stop = ownLifecycleReceiver( + scope, + () => {}, + () => {}, + { + ...clock, + waitForRateLimit: () => gate, + startReceiver: async (_scope, _active, _onError, _onReady, onClosed) => { + starts++; + closures.push(onClosed); + return () => {}; + }, + }, + ); + await flushUntil(() => closures.length === 1); + closures[0]({ classification: "rate-limited", retryAfterMs: 8_000 }); + assert.equal(clock.pending.values().next().value.delayMs, 8_000); + clock.fireNext(); + await Promise.resolve(); + assert.equal(starts, 1, "fresh subscription must wait for the active gate"); + stop(); + releaseGate(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(starts, 1, "scope cancellation must fence the late gate result"); +}); diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.ts b/desktop/src/features/agents/desktopLifecycleReceiver.ts new file mode 100644 index 00000000000..12a2a81ff29 --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycleReceiver.ts @@ -0,0 +1,142 @@ +import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientShared"; +import { waitForRateLimit } from "@/shared/api/relayRateLimitGate"; +import type { DesktopScope } from "./desktopList"; +import { receiveLifecycle } from "./desktopLifecycle"; +import { receiverErrorMessage } from "./desktopLifecycleDiagnostics"; + +export const RECEIVER_RECOVERY_DELAYS_MS = [1_000, 2_000, 4_000] as const; + +const CLOSED_MESSAGE = + "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests."; + +type StartReceiver = ( + scope: DesktopScope, + active: () => boolean, + onError: (message: string) => void, + onReady: () => void, + onClosed: (recovery: LiveSubscriptionClosedRecovery) => void, +) => Promise<() => void>; + +type ReceiverOwnerDependencies = { + startReceiver?: StartReceiver; + waitForRateLimit?: () => Promise; + setTimer?: (callback: () => void, delayMs: number) => number; + clearTimer?: (timer: number) => void; +}; + +/** + * Owns one lifecycle receiver scope. Recovery always creates a fresh live-only + * subscription and lets receiveLifecycle repeat projection sync before it + * admits events. The attempt budget belongs to this owner and is intentionally + * not reset by a successful EOSE/sync followed by another CLOSED. + */ +export function ownLifecycleReceiver( + scope: DesktopScope, + onError: (message: string) => void, + onReady: () => void, + dependencies: ReceiverOwnerDependencies = {}, +) { + const startReceiver: StartReceiver = + dependencies.startReceiver ?? + ((receiverScope, active, reportError, ready, closed) => + receiveLifecycle( + receiverScope, + active, + reportError, + undefined, + undefined, + ready, + closed, + )); + const waitForGate = dependencies.waitForRateLimit ?? waitForRateLimit; + const setTimer = + dependencies.setTimer ?? + ((callback, delayMs) => window.setTimeout(callback, delayMs)); + const clearTimer = + dependencies.clearTimer ?? ((timer) => window.clearTimeout(timer)); + + let stopped = false; + let generation = 0; + let recoveryAttempt = 0; + let timer: number | undefined; + let closeCurrent: (() => void) | undefined; + + const current = (token: number) => !stopped && generation === token; + + const retireCurrent = () => { + const close = closeCurrent; + closeCurrent = undefined; + close?.(); + }; + + const recover = ( + token: number, + terminalMessage: string, + recovery: LiveSubscriptionClosedRecovery, + ) => { + if (!current(token)) return; + generation++; + retireCurrent(); + + if ( + recovery.classification === "terminal" || + recoveryAttempt >= RECEIVER_RECOVERY_DELAYS_MS.length + ) { + onError(terminalMessage); + return; + } + + const delayMs = Math.max( + RECEIVER_RECOVERY_DELAYS_MS[recoveryAttempt], + recovery.retryAfterMs, + ); + recoveryAttempt++; + const waitingGeneration = generation; + timer = setTimer(() => { + timer = undefined; + if (stopped || generation !== waitingGeneration) return; + void waitForGate().then(() => { + if (!stopped && generation === waitingGeneration) start(); + }); + }, delayMs); + }; + + const start = () => { + const token = ++generation; + void startReceiver( + scope, + () => current(token), + (message) => { + if (current(token)) onError(message); + }, + () => { + if (current(token)) onReady(); + }, + (recovery) => recover(token, CLOSED_MESSAGE, recovery), + ) + .then((close) => { + if (current(token)) closeCurrent = close; + else close(); + }) + .catch((error) => { + if (current(token)) + recover(token, receiverErrorMessage(error), { + classification: "retryable", + retryAfterMs: 0, + }); + }); + }; + + start(); + + return () => { + if (stopped) return; + stopped = true; + generation++; + if (timer !== undefined) { + clearTimer(timer); + timer = undefined; + } + retireCurrent(); + }; +} diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs index 35e52f7aab0..17aff55e75a 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -136,7 +136,7 @@ test("mounted Start exposes unavailable provisioning and exact retry; Restart re } }); -test("receiver failure is a scope-owned notification, not pre-shell layout", async () => { +test("terminal receiver failure is a scope-owned notification, not pre-shell layout", async () => { const originalRaf = globalThis.requestAnimationFrame; globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); const dom = new JSDOM("
    ", { @@ -157,7 +157,6 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy let closed = 0; let rejectLate; let delayed = false; - let storageAvailable = false; let subscribed = 0; relayClient.fetchEvents = async () => { if (delayed) @@ -181,10 +180,7 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy }; }; window.__TAURI_INTERNALS__ = { - invoke: async () => { - if (storageAvailable) return; - throw new Error("fixture: storage unavailable"); - }, + invoke: async () => {}, }; const root = createRoot(document.getElementById("root")); const scope = { owner: "owner", community: "wss://one.example" }; @@ -201,14 +197,18 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy 0, "startup must not render in-flow failure UI", ); + assert.equal(warnings().length, 0); + await React.act(async () => + readiness("closed", { classification: "terminal", retryAfterMs: 0 }), + ); assert.equal(warnings().length, 1); assert.equal( warnings()[0].title, - "Desktop lifecycle receiver is unavailable (projection: request failed).", + "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.", ); assert.equal(warnings()[0].duration, Infinity); assert.equal(warnings()[0].closeButton, true); - readiness("closed"); + readiness("closed", { classification: "terminal", retryAfterMs: 0 }); assert.equal( warnings().length, 1, @@ -216,7 +216,6 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy ); const retryAction = warnings()[0].action; assert.equal(retryAction.label, "Retry receiver"); - storageAvailable = true; await React.act(async () => retryAction.onClick()); assert.equal(subscribed, 2, "explicit recovery starts a new live receiver"); assert.equal( @@ -230,7 +229,7 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy ), ); assert.equal(warnings().length, 0, "leaving the scope removes its warning"); - readiness("closed"); + readiness("closed", { classification: "terminal", retryAfterMs: 0 }); assert.equal( warnings().length, 0, diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx index 0b6b63d67be..8212eb7258b 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -3,13 +3,9 @@ import { toast } from "sonner"; import { Button } from "@/shared/ui/button"; import type { RelayEvent } from "@/shared/api/types"; import type { DesktopRow, DesktopScope } from "../desktopList"; -import { - lifecycleClient, - receiveLifecycle, - type LifecycleOutcome, -} from "../desktopLifecycle"; +import { lifecycleClient, type LifecycleOutcome } from "../desktopLifecycle"; +import { ownLifecycleReceiver } from "../desktopLifecycleReceiver"; import { useRelayAgentsQuery } from "../hooks"; -import { receiverErrorMessage } from "../desktopLifecycleDiagnostics"; export function DesktopLifecycleReceiver({ scope, @@ -21,7 +17,7 @@ export function DesktopLifecycleReceiver({ useEffect(() => { if (!owner || !community) return; let active = true; - let close: (() => void) | undefined; + let stop = () => {}; let notification: string | number | undefined; const reportError = (message: string) => { if (!active) return; @@ -37,32 +33,18 @@ export function DesktopLifecycleReceiver({ onClick: () => { if (!active) return; active = false; - close?.(); + stop(); retry(attempt + 1); }, }, }); }; - void receiveLifecycle( - { owner, community }, - () => active, - reportError, - undefined, - undefined, - () => { - if (active && notification !== undefined) toast.dismiss(notification); - }, - ) - .then((fn) => { - if (active) close = fn; - else fn(); - }) - .catch((error) => { - reportError(receiverErrorMessage(error)); - }); + stop = ownLifecycleReceiver({ owner, community }, reportError, () => { + if (active && notification !== undefined) toast.dismiss(notification); + }); return () => { active = false; - close?.(); + stop(); if (notification !== undefined) toast.dismiss(notification); }; }, [owner, community, attempt]); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index a7c0bded02d..5ba009e6476 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -164,6 +164,12 @@ export class RelayClient { sub.reject(error); } else { clearClosedRetry(sub); + sub.resolveReady?.("closed"); + sub.onState?.("closed", { + classification: "terminal", + retryAfterMs: 0, + }); + sub.resolveReady = undefined; } this.subscriptions.delete(subId); } @@ -635,19 +641,23 @@ export class RelayClient { resolveReady("timeout"); }, readinessTimeoutMs); - this.subscriptions.set(subId, { + const subscription: Extract = { mode: "live", filter, onEvent, resolveReady, onState: options.onState, closedRecovery: options.closedRecovery ?? "shared", - }); + }; + this.subscriptions.set(subId, subscription); try { await this.sendRawWithReconnectRetry( ["REQ", subId, filter], "Failed to restore relay subscription.", + () => + subscription.closedRecovery !== "explicit" || + this.subscriptions.get(subId) === subscription, ); } catch (error) { window.clearTimeout(fallbackTimeout); @@ -709,6 +719,7 @@ export class RelayClient { private async sendRawWithReconnectRetry( payload: unknown[], fallbackMessage: string, + retryStillOwned: () => boolean = () => true, ) { try { await this.sendRaw(payload); @@ -717,8 +728,13 @@ export class RelayClient { error, fallbackMessage, ); + // resetConnection may retire an explicit subscription synchronously. + // Never put its now-ownerless REQ onto the replacement connection; + // shared subscriptions retain their existing reconnect retry behavior. + if (!retryStillOwned()) throw normalizedError; try { await this.ensureConnected(); + if (!retryStillOwned()) throw normalizedError; await this.sendRaw(payload); } catch (retryError) { throw this.recoverFromSocketFailure( @@ -1077,7 +1093,10 @@ export class RelayClient { continue; } subscription.resolveReady?.("closed"); - subscription.onState?.("closed"); + subscription.onState?.("closed", { + classification: options?.reconnect === false ? "terminal" : "retryable", + retryAfterMs: 0, + }); subscription.resolveReady = undefined; clearClosedRetry(subscription); if (subscription.closedRecovery === "explicit") { diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 925c74b573d..09064dc6276 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -1,4 +1,5 @@ import type { RelayEvent } from "@/shared/api/types"; +import type { RelayClosedClass } from "@/shared/api/relayClosedPolicy"; /** * Observable connection state for the relay singleton. @@ -70,16 +71,26 @@ type FirstEventSubscription = { export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout"; +export type LiveSubscriptionClosedRecovery = { + classification: RelayClosedClass; + /** Minimum delay before the owner creates a fresh subscription. */ + retryAfterMs: number; +}; + /** * Optional lifecycle policy for a live subscription. * * Most subscriptions keep the shared reconnect/CLOSED recovery behavior. A * command receiver can instead request explicit recovery: every CLOSED retires - * that subscription and `onState` remains observable after initial EOSE so the - * owning UI can offer a deliberate fresh subscription. + * that subscription and `onState` remains observable after initial EOSE. The + * owner receives only a safe recovery class/delay and decides whether to create + * a fresh subscription or require deliberate retry. */ export type LiveSubscriptionOptions = { - onState?: (state: LiveSubscriptionReadiness) => void; + onState?: ( + state: LiveSubscriptionReadiness, + closed?: LiveSubscriptionClosedRecovery, + ) => void; closedRecovery?: "shared" | "explicit"; }; @@ -88,7 +99,10 @@ type LiveSubscription = { filter: RelaySubscriptionFilter; onEvent: (event: RelayEvent) => void; resolveReady?: (readiness: LiveSubscriptionReadiness) => void; - onState?: (state: LiveSubscriptionReadiness) => void; + onState?: ( + state: LiveSubscriptionReadiness, + closed?: LiveSubscriptionClosedRecovery, + ) => void; closedRecovery: "shared" | "explicit"; lastSeenCreatedAt?: number; /** diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index 8f1a68287d2..d249bf2406d 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -123,10 +123,6 @@ function recoverLiveSubscriptionFromClosed({ message: string; sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise; }) { - subscription.resolveReady?.("closed"); - subscription.onState?.("closed"); - subscription.resolveReady = undefined; - const closedClass = classifyRelayClosed(message); if (closedClass === "rate-limited") { @@ -134,6 +130,13 @@ function recoverLiveSubscriptionFromClosed({ activateRateLimit(hintSeconds); } + subscription.resolveReady?.("closed"); + subscription.onState?.("closed", { + classification: closedClass, + retryAfterMs: closedClass === "rate-limited" ? rateLimitRemainingMs() : 0, + }); + subscription.resolveReady = undefined; + if (subscription.closedRecovery === "explicit") { // Command receivers must not survive CLOSED into shared re-subscription. // Their owner presents an explicit fresh-receiver action instead. diff --git a/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs index 587bfae1f28..a52d408bec3 100644 --- a/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs +++ b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs @@ -5,6 +5,8 @@ let fakeNow = 0; let nextTimerId = 1; const pendingTimers = new Map(); const sentFrames = []; +const sendAttempts = []; +let failNextSend = false; globalThis.window = { setTimeout: (fn, ms) => { @@ -15,7 +17,14 @@ globalThis.window = { clearTimeout: (id) => pendingTimers.delete(id), __TAURI_INTERNALS__: { invoke: async (command, args) => { - if (command === "plugin:websocket|send") sentFrames.push(args); + if (command === "plugin:websocket|send") { + sendAttempts.push(args); + if (failNextSend) { + failNextSend = false; + throw new Error("fixture first send failed"); + } + sentFrames.push(args); + } }, }, }; @@ -30,6 +39,8 @@ function resetHarness() { nextTimerId = 1; pendingTimers.clear(); sentFrames.length = 0; + sendAttempts.length = 0; + failNextSend = false; resetRateLimitGate(); } @@ -117,17 +128,21 @@ test("persistent state reports timeout, late EOSE, then CLOSED through RelayClie await close(); }); -for (const [label, message] of [ - ["terminal", "restricted: access revoked"], - ["retryable", "error: storage temporarily unavailable"], +for (const [label, message, classification] of [ + ["terminal", "restricted: access revoked", "terminal"], + ["retryable", "error: storage temporarily unavailable", "retryable"], ]) { test(`explicit recovery retires an EOSE-ready ${label} CLOSED without re-REQ`, async () => { resetHarness(); const client = connectedClient(); const states = []; + const recoveries = []; const { opened, subId } = await openLive(client, { closedRecovery: "explicit", - onState: (state) => states.push(state), + onState: (state, recovery) => { + states.push(state); + if (recovery) recoveries.push(recovery); + }, }); await deliver(client, ["EOSE", subId]); const close = await opened; @@ -137,6 +152,7 @@ for (const [label, message] of [ await Promise.resolve(); assert.deepEqual(states, ["eose", "closed"]); + assert.deepEqual(recoveries, [{ classification, retryAfterMs: 0 }]); assert.equal(client.subscriptions.has(subId), false); assert.equal(sentProtocolFrames("REQ").length, 1); await close(); @@ -173,6 +189,112 @@ test("connection reset reports CLOSED and retires only explicit-recovery subscri client.disconnect(); }); +test("disconnect reports terminal CLOSED before retiring an explicit subscription", async () => { + resetHarness(); + const client = connectedClient(); + const states = []; + const recoveries = []; + const explicit = await openLive(client, { + closedRecovery: "explicit", + onState: (state, recovery) => { + states.push(state); + if (recovery) recoveries.push(recovery); + }, + }); + await deliver(client, ["EOSE", explicit.subId]); + await explicit.opened; + + client.disconnect(); + + assert.deepEqual(states, ["eose", "closed"]); + assert.deepEqual(recoveries, [ + { classification: "terminal", retryAfterMs: 0 }, + ]); + assert.equal(client.subscriptions.has(explicit.subId), false); +}); + +test("explicit rate-limited CLOSED exposes only classified recovery and gate delay", async () => { + resetHarness(); + const client = connectedClient(); + let recovery; + const explicit = await openLive(client, { + closedRecovery: "explicit", + onState: (state, detail) => { + if (state === "closed") recovery = detail; + }, + }); + await deliver(client, ["EOSE", explicit.subId]); + await explicit.opened; + + await deliver(client, [ + "CLOSED", + explicit.subId, + "rate-limited: private relay detail; retry in 4s", + ]); + + assert.deepEqual(recovery, { + classification: "rate-limited", + retryAfterMs: 4_000, + }); + assert.equal( + JSON.stringify(recovery).includes("private relay detail"), + false, + "raw relay payload must not cross the recovery contract", + ); +}); + +test("explicit retirement during first send failure prevents a fresh ownerless REQ", async () => { + resetHarness(); + const client = connectedClient(); + let ensureCalls = 0; + client.ensureConnected = async () => { + ensureCalls++; + if (ensureCalls > 1) client.wsId = 8; + return client.connectionGeneration; + }; + failNextSend = true; + + await assert.rejects( + client.subscribeLive( + { kinds: [50182], authors: ["owner"], limit: 0 }, + () => {}, + undefined, + 5_000, + { closedRecovery: "explicit" }, + ), + /fixture first send failed/, + ); + + assert.equal( + ensureCalls, + 1, + "retired subscription must not reconnect to retry", + ); + assert.equal(sendAttempts.length, 1); + assert.equal(sentProtocolFrames("REQ").length, 0); + assert.equal(client.subscriptions.size, 0); +}); + +test("ordinary subscription still retries its first failed send", async () => { + resetHarness(); + const client = connectedClient(); + let ensureCalls = 0; + client.ensureConnected = async () => { + ensureCalls++; + if (ensureCalls > 1) client.wsId = 8; + return client.connectionGeneration; + }; + failNextSend = true; + + const close = await client.subscribeLive({ kinds: [9], limit: 0 }, () => {}); + + assert.equal(ensureCalls, 2); + assert.equal(sendAttempts.length, 2); + assert.equal(sentProtocolFrames("REQ").length, 1); + await close(); + client.disconnect(); +}); + test("default live subscribers retain shared retry after retryable CLOSED", async () => { resetHarness(); const client = connectedClient(); diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts index 97d6b69cb1f..5a49175d085 100644 --- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts +++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts @@ -116,10 +116,11 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => }); await page.goto("/"); // A failed global receiver must remain visible without entering the shell's - // layout flow. This also forces the error to settle before measuring chrome. + // layout flow. Wait through its bounded 1s/2s/4s recovery budget before + // measuring chrome; an intermediate transient failure need not notify. await expect( page.getByText(/Desktop lifecycle receiver is unavailable/), - ).toBeVisible(); + ).toBeVisible({ timeout: 15_000 }); // Lock the native and webview placements together: removing this explicit // Tauri inset or shifting the nav row regresses the macOS chrome alignment. From ef58314dd5da1fcbbcde6ebe757544ea42a83435 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 5 Sep 2026 00:01:45 -0400 Subject: [PATCH 29/51] fix(desktop): do not retry terminal receiver initialization Signed-off-by: Logan Johnson --- desktop/src/features/agents/AGENTS.md | 6 ++- .../agents/desktopLifecycleDiagnostics.ts | 9 ++++ .../agents/desktopLifecycleReceiver.test.mjs | 44 +++++++++++++++++++ .../agents/desktopLifecycleReceiver.ts | 10 ++++- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index e48401b4765..a6b0855394d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -318,8 +318,10 @@ failures and transient CLOSED states recover through a bounded receiver-owner budget; each attempt uses a fresh live-only subscription and repeats projection-only sync before admission. Terminal closure or exhausted recovery stays in the scope-owned notification, whose deliberate retry resets the receiver -budget. Recovery must discard queued callbacks from the retired receiver, not -retry an operation, and must respect the relay rate-limit gate. +budget. A known latched-terminal relay session also reports immediately during +initialization without consuming that budget; unknown and transient failures +remain bounded retries. Recovery must discard queued callbacks from the retired +receiver, not retry an operation, and must respect the relay rate-limit gate. A readiness timeout is unconfirmed delivery, not a failed initialization; late EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request; the relay redelivers stored Stop duplicates without repeating relay side effects. diff --git a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts index aebe01c41a9..cee8e60769e 100644 --- a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts +++ b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts @@ -6,9 +6,17 @@ type Stage = | "projection" | "reconciliation"; +export type LifecycleReceiverFailureClassification = "retryable" | "terminal"; + export class LifecycleReceiverError extends Error { + readonly recoveryClassification: LifecycleReceiverFailureClassification; + constructor(stage: Stage, error: unknown) { const value = error instanceof Error ? error.message : error; + const recoveryClassification = + value === "Relay session is terminal; cannot reconnect." + ? "terminal" + : "retryable"; const reason = value === "closed" ? "subscription closed" @@ -20,6 +28,7 @@ export class LifecycleReceiverError extends Error { ? "history timed out" : "request failed"; super(`Desktop lifecycle receiver is unavailable (${stage}: ${reason}).`); + this.recoveryClassification = recoveryClassification; } } diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs index 70b96890052..6948a55ca65 100644 --- a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs +++ b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs @@ -133,6 +133,50 @@ test("first subscribe failure recovers without a reconnect callback and syncs be assert.equal(closed, 1); }); +test("terminal relay-session initialization failure does not consume the recovery budget", async () => { + const clock = timers(); + let subscribeCalls = 0; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async () => { + subscribeCalls++; + throw new Error("Relay session is terminal; cannot reconnect."); + }, + fetchEvents: async () => assert.fail("terminal session must not sync"), + publishEvent: async () => {}, + }; + const errors = []; + ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => {}, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async () => + assert.fail("terminal session must not invoke native IPC"), + relay, + onReady, + onClosed, + ), + }, + ); + + await flushUntil(() => errors.length === 1); + assert.equal(subscribeCalls, 1); + assert.equal(clock.pending.size, 0); + assert.equal( + errors[0], + "Desktop lifecycle receiver is unavailable (subscription: relay session requires reconnection).", + ); +}); + test("transient CLOSED before readiness retires and replaces the whole receiver", async () => { const clock = timers(); let subscribeCalls = 0; diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.ts b/desktop/src/features/agents/desktopLifecycleReceiver.ts index 12a2a81ff29..62417287e91 100644 --- a/desktop/src/features/agents/desktopLifecycleReceiver.ts +++ b/desktop/src/features/agents/desktopLifecycleReceiver.ts @@ -2,7 +2,10 @@ import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientSha import { waitForRateLimit } from "@/shared/api/relayRateLimitGate"; import type { DesktopScope } from "./desktopList"; import { receiveLifecycle } from "./desktopLifecycle"; -import { receiverErrorMessage } from "./desktopLifecycleDiagnostics"; +import { + LifecycleReceiverError, + receiverErrorMessage, +} from "./desktopLifecycleDiagnostics"; export const RECEIVER_RECOVERY_DELAYS_MS = [1_000, 2_000, 4_000] as const; @@ -121,7 +124,10 @@ export function ownLifecycleReceiver( .catch((error) => { if (current(token)) recover(token, receiverErrorMessage(error), { - classification: "retryable", + classification: + error instanceof LifecycleReceiverError + ? error.recoveryClassification + : "retryable", retryAfterMs: 0, }); }); From 546f5ec16aea9853aa09ab58a10a6fa620a6db65 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 5 Sep 2026 00:20:33 -0400 Subject: [PATCH 30/51] fix(desktop): preserve runtime community authority and refuse lossy receipts Signed-off-by: Logan Johnson --- crates/buzz-core/src/relay.rs | 19 +- desktop/src-tauri/src/commands/agents.rs | 4 +- .../src/managed_agents/remote_stop.rs | 18 +- .../src-tauri/src/managed_agents/restore.rs | 12 +- .../src-tauri/src/managed_agents/runtime.rs | 38 +- .../managed_agents/runtime/authority_tests.rs | 364 ++++++++++++++++++ .../src/managed_agents/runtime/process.rs | 108 +++++- .../src/managed_agents/runtime/spawn_key.rs | 17 + .../src/managed_agents/runtime/stop.rs | 185 ++++++++- .../managed_agents/runtime/test_fixtures.rs | 125 +++++- .../src/managed_agents/runtime/tests.rs | 73 +--- .../src/managed_agents/runtime_commands.rs | 251 ++++++++++-- .../src/managed_agents/runtime_types.rs | 160 +++++++- .../src/managed_agents/session_policy.rs | 4 +- .../src-tauri/src/managed_agents/storage.rs | 14 +- desktop/src-tauri/src/relay/scope.rs | 2 +- .../managedAgentReconciliationPlan.test.mjs | 14 +- ...managedAgentRuntimeReconciliation.test.mjs | 13 + .../agents/managedAgentRuntimeStatus.test.mjs | 54 ++- .../agents/managedAgentRuntimeStatus.ts | 54 ++- .../src/protectedFeatures/bestie/useBestie.ts | 4 +- 21 files changed, 1323 insertions(+), 210 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs diff --git a/crates/buzz-core/src/relay.rs b/crates/buzz-core/src/relay.rs index 77c74a069ad..ba14dc87db0 100644 --- a/crates/buzz-core/src/relay.rs +++ b/crates/buzz-core/src/relay.rs @@ -1,4 +1,4 @@ -//! Canonical relay identities shared by runtime components. +//! Legacy canonical relay identities shared by compatibility consumers. use thiserror::Error; use url::{Host, Url}; @@ -23,17 +23,16 @@ pub enum NormalizeRelayUrlError { MissingHost, } -/// Canonicalize a WebSocket relay URL for use as a runtime identity key. +/// Canonicalize a WebSocket relay URL for legacy equivalence consumers. /// -/// This is the sole normalizer for `(agent, relay)` process identity. It keeps -/// the WebSocket scheme, lowercases DNS hosts, folds all loopback spellings to -/// `127.0.0.1`, removes default ports and a root slash, and preserves non-root -/// paths and queries. It deliberately is **not** the NIP-42 AUTH comparison -/// helper in `buzz-auth`: AUTH validation is a security boundary with narrower -/// equivalence rules and must not be widened by runtime-key canonicalization. +/// Bestie scope and pollen/profile migration retain this historical behavior: +/// keep the WebSocket scheme, lowercase DNS hosts, fold all loopback spellings +/// to `127.0.0.1`, remove default ports and trailing slashes, and preserve +/// queries. Managed-agent process identity intentionally uses a scoped +/// host-preserving normalizer because relay hosts are tenant authorities. /// -/// Connection code may retain the configured URL; this canonical form is for -/// identity, receipts, status and deduplication. +/// This deliberately is **not** the NIP-42 AUTH comparison helper in +/// `buzz-auth`; changing either equivalence contract requires a separate review. pub fn normalize_relay_url(raw: &str) -> Result { let mut url = Url::parse(raw.trim()) .map_err(|error| NormalizeRelayUrlError::InvalidUrl(error.to_string()))?; diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index df092d7dc44..af1cae86f8c 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -176,8 +176,8 @@ async fn start_local_agent_with_preflight( replay_floor_unix: Option, ) -> Result { let launch_owner = workspace_owner_hex(state)?; - // Runtime keys fold loopback aliases for process bookkeeping, not tenant - // identity. Preserve the workspace authority across the preflight await. + // Runtime keys preserve the workspace host authority. Bind that same + // authority across the preflight await so the eventual spawn cannot move. let launch_relay = crate::relay::bind_expected_relay_scope( expected_relay_url, relay_ws_url_with_override(state), diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs index e6c93750389..81a21e353fd 100644 --- a/desktop/src-tauri/src/managed_agents/remote_stop.rs +++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs @@ -267,7 +267,10 @@ mod tests { let community = "ws://localhost:3037"; let agent = Keys::generate().public_key().to_hex(); let key = ManagedAgentRuntimeKey::new(&agent, community).unwrap(); - assert_ne!(key.relay_url, community); + assert_eq!(key.relay_url, community); + let numeric_community = "ws://127.0.0.1:3037"; + let numeric_key = ManagedAgentRuntimeKey::new(&agent, numeric_community).unwrap(); + assert_ne!(key, numeric_key); let scope = super::super::retention::RetentionScope { db_path: scoped_retention_db_path(&root.join("agents"), community, &owner), relay_url: community.into(), @@ -293,9 +296,16 @@ mod tests { ) .unwrap(); assert!(check_launch(app.handle(), &key, community, Some(&owner), None).is_err()); - // The numeric community is a different authority, even though the - // process bookkeeping key historically folds the two spellings. - assert!(check_launch(app.handle(), &key, &key.relay_url, Some(&owner), None).is_ok()); + // The numeric community is a different authority with its own runtime + // identity and fence database. + assert!(check_launch( + app.handle(), + &numeric_key, + numeric_community, + Some(&owner), + None + ) + .is_ok()); let resume = capture_resume(app.handle(), &key, community, &owner).unwrap(); assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_ok()); let newer = request(&keys, &target, 101); diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 5e282c239d4..9334b0dc0dd 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -397,12 +397,12 @@ pub async fn restore_managed_agents_on_launch( continue; }; let now = util::now_iso(); - let receipt = super::ManagedAgentRuntimeReceipt { - key: key.clone(), - pid: process.child.id(), - desktop_instance_id: super::current_instance_id(app), - started_at: now.clone(), - }; + let receipt = super::ManagedAgentRuntimeReceipt::new( + key.clone(), + process.child.id(), + super::current_instance_id(app), + now.clone(), + ); if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { let _ = super::terminate_process(process.child.id()); let _ = process.child.wait(); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 8efe5826291..adabff1ce16 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -41,11 +41,13 @@ mod process; #[cfg(test)] use process::{ buzz_marker_entry, name_matches_interpreter, name_matches_known_binary, - terminate_runtime_receipt_with, valid_agent_runtime_receipt_with, + select_pair_runtime_receipt_with, terminate_runtime_receipt_with, + valid_agent_runtime_receipt_with, }; pub(crate) use process::{ current_instance_id, process_belongs_to_us, process_has_buzz_marker, process_is_running, terminate_process, terminate_untracked_pair_runtime, valid_agent_runtime_receipt, + with_pair_runtime_receipt_authority, }; mod orphan_sweep; @@ -108,8 +110,8 @@ fn persona_drift_state( /// pin is ignored — see `effective_agent_relay_url`). Returns `None` for /// records that cannot form a valid pair key yet (e.g. key-less agents that /// mint keys on first start). -pub(crate) fn workspace_pair_key( - app: &AppHandle, +pub(crate) fn workspace_pair_key( + app: &AppHandle, record: &ManagedAgentRecord, ) -> Option { let state = app.state::(); @@ -444,8 +446,8 @@ pub(crate) fn spawn_with_effort_proof( /// publishes the triggering message before this spawn and passes its send /// timestamp here so the harness's first REQ replays past that message no /// matter how long the spawn takes. buzz-acp clamps stale floors to ~15 min. -pub fn spawn_agent_child( - app: &AppHandle, +pub fn spawn_agent_child( + app: &AppHandle, record: &ManagedAgentRecord, relay_url: &str, lazy: bool, @@ -466,8 +468,8 @@ pub fn spawn_agent_child( } #[allow(clippy::too_many_arguments)] -pub(crate) fn spawn_agent_child_with_broker( - app: &AppHandle, +pub(crate) fn spawn_agent_child_with_broker( + app: &AppHandle, record: &ManagedAgentRecord, relay_url: &str, lazy: bool, @@ -917,8 +919,8 @@ pub(crate) fn spawn_agent_child_with_broker( /// exact workspace-relay read the caller's scope assertion passed on; it never /// re-reads the mutable override (see `relay::scope`). The key comes from /// [`bound_runtime_key`] — the seam the spawn-key regressions exercise. -pub fn start_managed_agent_process( - app: &AppHandle, +pub fn start_managed_agent_process( + app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, owner_hex: Option<&str>, @@ -944,6 +946,10 @@ pub fn start_managed_agent_process( // Scalar PIDs are migration-only and never establish pair liveness. record.runtime_pid = None; + // A prior-session receipt is the only untracked process this pair may + // replace. Selection enforces host-preserving authority provenance and + // uses the ordinary process-tree termination contract. + terminate_untracked_pair_runtime(app, &key)?; let mut process = spawn_agent_child( app, record, @@ -954,12 +960,12 @@ pub fn start_managed_agent_process( resume, )?; let now = now_iso(); - let receipt = super::ManagedAgentRuntimeReceipt { - key: key.clone(), - pid: process.child.id(), - desktop_instance_id: current_instance_id(app), - started_at: now.clone(), - }; + let receipt = super::ManagedAgentRuntimeReceipt::new( + key.clone(), + process.child.id(), + current_instance_id(app), + now.clone(), + ); if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { let _ = terminate_process(process.child.id()); let _ = process.child.wait(); @@ -979,7 +985,7 @@ pub fn start_managed_agent_process( } #[cfg(test)] -mod test_fixtures; +pub(super) mod test_fixtures; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs new file mode 100644 index 00000000000..c27762cea80 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs @@ -0,0 +1,364 @@ +//! Runtime authority migration and production Start regressions. + +use super::super as runtime; +use super::receipt_fixture; + +#[test] +fn legacy_receipt_validation_uses_legacy_rendering_for_global_ownership() { + let mut receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new( + "aa".repeat(32), + "wss://relay.example?mode=one", + ) + .unwrap(), + ); + receipt.authority_version = 0; + // url::Url serialization retained the root slash before a query in V0, + // while the scoped runtime renderer intentionally removes that root slash. + receipt.key.relay_url = "wss://relay.example/?mode=one".into(); + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + + assert!(runtime::valid_agent_runtime_receipt_with( + &path, + &receipt, + "test-instance", + |_| true, + |_, _| true, + )); +} + +#[test] +fn legacy_normalizer_loss_boundaries_are_pinned_to_the_real_url_renderer() { + let normalize = buzz_core_pkg::relay::normalize_relay_url; + assert_eq!( + normalize("wss://relay.example/room/").unwrap(), + "wss://relay.example/room" + ); + assert_eq!( + normalize("wss://relay.example/room?tail=/").unwrap(), + "wss://relay.example/room?tail=" + ); + assert_eq!( + normalize("wss://relay.example/?mode=one").unwrap(), + "wss://relay.example/?mode=one" + ); + assert_eq!( + normalize("wss://relay.example/?").unwrap(), + "wss://relay.example/?" + ); +} + +#[test] +fn replacement_removes_receipt_only_after_confirmed_exit() { + use std::cell::{Cell, RefCell}; + + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(), + ); + let path = std::path::Path::new("pair.json"); + let terminated = Cell::new(None); + let polls = Cell::new(0); + let removed = RefCell::new(None); + + runtime::terminate_runtime_receipt_with( + path, + &receipt, + |pid| { + terminated.set(Some(pid)); + Ok(()) + }, + |_| { + let poll = polls.get() + 1; + polls.set(poll); + poll < 2 + }, + |path| *removed.borrow_mut() = Some(path.to_path_buf()), + ) + .unwrap(); + + assert_eq!(terminated.get(), Some(receipt.pid)); + assert_eq!(polls.get(), 2); + assert_eq!(removed.into_inner().as_deref(), Some(path)); +} + +#[test] +fn replacement_failure_keeps_receipt() { + use std::cell::Cell; + + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(), + ); + let removed = Cell::new(false); + let error = runtime::terminate_runtime_receipt_with( + std::path::Path::new("pair.json"), + &receipt, + |_| Err("signal failed".into()), + |_| false, + |_| removed.set(true), + ) + .unwrap_err(); + + assert_eq!(error, "signal failed"); + assert!(!removed.get()); +} + +#[cfg(unix)] +#[test] +fn production_start_refuses_live_unversioned_receipt_before_spawn() { + let _path_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let old_home = std::env::var_os("HOME"); + let old_xdg = std::env::var_os("XDG_DATA_HOME"); + struct RestoreEnv(Option, Option); + impl Drop for RestoreEnv { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.1.take() { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + } + let _restore_env = RestoreEnv(old_home, old_xdg); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + + let relay = "wss://relay.example"; + let pubkey = "aa".repeat(32); + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let instance_id = runtime::current_instance_id(app.handle()); + let mut child = runtime::test_fixtures::MarkedTestChild::spawn(&instance_id).unwrap(); + assert!(runtime::process_has_buzz_marker(child.id(), &instance_id)); + + let key = crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, relay).unwrap(); + let receipt = crate::managed_agents::ManagedAgentRuntimeReceipt { + authority_version: 0, + key: key.clone(), + pid: child.id(), + desktop_instance_id: instance_id, + started_at: "now".into(), + }; + crate::managed_agents::write_agent_runtime_receipt(app.handle(), &receipt).unwrap(); + + let mut record = runtime::test_fixtures::fixture( + crate::managed_agents::RespondTo::OwnerOnly, + Vec::new(), + None, + ); + record.pubkey = pubkey; + record.acp_command = "a-command-that-must-not-be-resolved".into(); + let bound = crate::relay::bind_expected_relay_scope(None, relay.into()).unwrap(); + let mut runtimes = std::collections::HashMap::new(); + + let error = runtime::start_managed_agent_process( + app.handle(), + &mut record, + &mut runtimes, + None, + &bound, + None, + None, + ) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); + assert!(!error.contains("crash")); + assert!(runtimes.is_empty()); + assert!(child.child_mut().try_wait().unwrap().is_none()); + assert!( + crate::managed_agents::read_all_agent_runtime_receipts(app.handle()) + .iter() + .any(|(_, candidate)| candidate == &receipt) + ); + crate::managed_agents::remove_agent_runtime_receipt(app.handle(), &key); +} + +#[test] +fn receipt_selection_refuses_ambiguous_unversioned_loopback_authority() { + let mut receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://127.0.0.1:3000") + .unwrap(), + ); + receipt.authority_version = 0; + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + for requested_relay in [ + "ws://127.0.0.1:3000", + "ws://localhost:3000", + "ws://127.0.0.2:3000", + "ws://[::1]:3000", + ] { + let requested = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), requested_relay) + .unwrap(); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path.clone(), receipt.clone())], + &requested, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!( + error.contains("cannot prove the requested community authority"), + "legacy receipt must not prove {requested_relay}" + ); + } +} + +#[test] +fn receipt_selection_keeps_versioned_loopback_authorities_disjoint() { + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://127.0.0.1:3000") + .unwrap(), + ); + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + let requested = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://localhost:3000") + .unwrap(); + + let selected = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &requested, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap(); + assert!(selected.is_none()); +} + +#[test] +fn receipt_selection_refuses_unversioned_non_loopback_lossy_urls() { + let pubkey = "aa".repeat(32); + for (stored_relay, requested_relay) in [ + ("wss://relay.example/room", "wss://relay.example/room"), + ("wss://relay.example/room", "wss://relay.example/room/"), + ( + "wss://relay.example/?mode=one", + "wss://relay.example?mode=one", + ), + ( + "wss://relay.example/room?tail=", + "wss://relay.example/room?tail=/", + ), + ] { + let mut receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new( + pubkey.clone(), + "wss://relay.example", + ) + .unwrap(), + ); + receipt.authority_version = 0; + receipt.key.relay_url = stored_relay.into(); + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + let requested = + crate::managed_agents::ManagedAgentRuntimeKey::new(pubkey.clone(), requested_relay) + .unwrap(); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &requested, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!( + error.contains("cannot prove the requested community authority"), + "legacy {stored_relay} must not prove {requested_relay}" + ); + } +} + +#[test] +fn legacy_renderer_collapses_repeated_root_slashes_but_modern_keys_do_not() { + let parsed = url::Url::parse("wss://relay.example//").unwrap(); + assert_eq!(parsed.path(), "//"); + assert_eq!( + buzz_core_pkg::relay::normalize_relay_url("wss://relay.example//").unwrap(), + "wss://relay.example" + ); + let root = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(); + let repeated = crate::managed_agents::ManagedAgentRuntimeKey::new( + "aa".repeat(32), + "wss://relay.example//", + ) + .unwrap(); + assert_ne!(root, repeated); +} + +#[test] +fn receipt_selection_refuses_unversioned_root_authority() { + let key = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(); + let mut receipt = receipt_fixture(key.clone()); + receipt.authority_version = 0; + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &key, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); +} + +#[test] +fn repeated_root_request_is_refused_for_colliding_unversioned_receipt() { + let pubkey = "aa".repeat(32); + let stored = + crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, "wss://relay.example").unwrap(); + let requested = + crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, "wss://relay.example//") + .unwrap(); + let mut receipt = receipt_fixture(stored); + receipt.authority_version = 0; + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &requested, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); +} + +#[test] +fn receipt_selection_refuses_unknown_future_authority_version() { + let key = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(); + let mut receipt = receipt_fixture(key.clone()); + receipt.authority_version = crate::managed_agents::RUNTIME_AUTHORITY_RECEIPT_VERSION + 1; + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &key, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); +} + +// ── workspace pair-key resolution (summary/stop scoping) ──────────────── diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs index 26aa26f0747..ac3393394ee 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/process.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs @@ -395,7 +395,9 @@ pub(crate) fn valid_agent_runtime_receipt( /// Injectable version of `valid_agent_runtime_receipt` for testing. /// `is_running(pid)` and `has_marker(pid, instance_id)` can be substituted by -/// test doubles without spawning real processes. +/// test doubles without spawning real processes. Validity here proves only +/// instance ownership for global cleanup; pair actions must additionally use +/// `select_pair_runtime_receipt_with` to establish authority provenance. pub(crate) fn valid_agent_runtime_receipt_with( path: &std::path::Path, receipt: &super::super::ManagedAgentRuntimeReceipt, @@ -403,12 +405,21 @@ pub(crate) fn valid_agent_runtime_receipt_with( is_running: impl Fn(u32) -> bool, has_marker: impl Fn(u32, &str) -> bool, ) -> bool { - let Ok(canonical) = + let key_rendering_is_valid = if receipt.authority_version == 0 { + receipt.key.pubkey.len() == 64 + && receipt + .key + .pubkey + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + && receipt.key.pubkey == receipt.key.pubkey.to_ascii_lowercase() + && buzz_core_pkg::relay::normalize_relay_url(&receipt.key.relay_url) + .is_ok_and(|legacy| legacy == receipt.key.relay_url) + } else { ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) - else { - return false; + .is_ok_and(|canonical| canonical == receipt.key) }; - canonical == receipt.key + key_rendering_is_valid && path.file_name().and_then(|name| name.to_str()) == Some(&format!("{}.json", receipt.key.runtime_id())) && receipt.desktop_instance_id == instance_id @@ -441,20 +452,93 @@ pub(super) fn terminate_runtime_receipt_with( )) } +fn receipt_has_proven_pair_authority(receipt: &super::super::ManagedAgentRuntimeReceipt) -> bool { + receipt.authority_version == super::super::RUNTIME_AUTHORITY_RECEIPT_VERSION +} + +fn unversioned_receipt_may_ambiguously_match( + receipt: &super::super::ManagedAgentRuntimeReceipt, + key: &ManagedAgentRuntimeKey, +) -> bool { + if receipt.authority_version != 0 || !receipt.key.pubkey.eq_ignore_ascii_case(&key.pubkey) { + return false; + } + buzz_core_pkg::relay::normalize_relay_url(&key.relay_url) + .is_ok_and(|legacy_relay| legacy_relay == receipt.key.relay_url) +} + +/// Select a receipt only when it proves the requested pair authority. +/// +/// Unversioned receipts used a lossy normalizer that folded loopback hosts and +/// stripped every terminal slash. They can establish instance ownership for +/// global cleanup but cannot prove which new runtime key a pair-scoped action +/// owns, including an apparently exact root URL: `wss://h//` and `wss://h` +/// share the V0 rendering but are distinct modern keys. +pub(crate) fn select_pair_runtime_receipt_with( + entries: Vec<(std::path::PathBuf, super::super::ManagedAgentRuntimeReceipt)>, + key: &ManagedAgentRuntimeKey, + instance_id: &str, + is_running: impl Fn(u32) -> bool, + has_marker: impl Fn(u32, &str) -> bool, +) -> Result, String> { + let mut selected = None; + for (path, receipt) in entries { + if !valid_agent_runtime_receipt_with(&path, &receipt, instance_id, &is_running, &has_marker) + || !receipt.key.pubkey.eq_ignore_ascii_case(&key.pubkey) + { + continue; + } + + let exact = receipt.key == *key; + if (exact && !receipt_has_proven_pair_authority(&receipt)) + || unversioned_receipt_may_ambiguously_match(&receipt, key) + { + return Err( + "Runtime receipt cannot prove the requested community authority; quit Buzz Desktop normally, then reopen it before retrying this Start or Stop" + .into(), + ); + } + if exact { + selected = Some((path, receipt)); + } + } + Ok(selected) +} + +/// Run a pair action only after every live receipt that could name the pair +/// has proven authority. The check itself performs no process termination. +pub(crate) fn with_pair_runtime_receipt_authority( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + effect: impl FnOnce() -> Result, +) -> Result { + let instance_id = current_instance_id(app); + select_pair_runtime_receipt_with( + super::super::read_all_agent_runtime_receipts(app), + key, + &instance_id, + process_is_running, + process_has_buzz_marker, + )?; + effect() +} + /// Replace a valid prior-session process before registering a new child for /// the same pair. The caller must hold the runtime transition lock so receipt /// inspection, termination, spawn, and registration cannot race shutdown or /// another start. -pub(crate) fn terminate_untracked_pair_runtime( - app: &AppHandle, +pub(crate) fn terminate_untracked_pair_runtime( + app: &AppHandle, key: &ManagedAgentRuntimeKey, ) -> Result<(), String> { let instance_id = current_instance_id(app); - let Some((path, receipt)) = super::super::read_all_agent_runtime_receipts(app) - .into_iter() - .find(|(path, receipt)| { - receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) - }) + let Some((path, receipt)) = select_pair_runtime_receipt_with( + super::super::read_all_agent_runtime_receipts(app), + key, + &instance_id, + process_is_running, + process_has_buzz_marker, + )? else { return Ok(()); }; diff --git a/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs index fe302ffc67e..1869a1f7642 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs @@ -81,4 +81,21 @@ mod tests { let key = bound_runtime_key(&record, &bound).expect("keyable record and relay"); assert_eq!(key.relay_url, "wss://tenant-a.example"); } + + #[test] + fn production_spawn_key_preserves_loopback_community_authority() { + let record = record(&"cc".repeat(32), ""); + let localhost = + crate::relay::bind_expected_relay_scope(None, "ws://localhost:3000".to_string()) + .unwrap(); + let numeric = + crate::relay::bind_expected_relay_scope(None, "ws://127.0.0.1:3000".to_string()) + .unwrap(); + + let localhost_key = bound_runtime_key(&record, &localhost).unwrap(); + let numeric_key = bound_runtime_key(&record, &numeric).unwrap(); + assert_eq!(localhost_key.relay_url, "ws://localhost:3000"); + assert_eq!(numeric_key.relay_url, "ws://127.0.0.1:3000"); + assert_ne!(localhost_key, numeric_key); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 0c13937ff27..9b619dc19d0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -120,31 +120,33 @@ fn stop_legacy_scalar_pid( /// 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, +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)?; + Some(pair_key) => super::with_pair_runtime_receipt_authority(app, &pair_key, || { + if runtimes.contains_key(&pair_key) { + stop_managed_agent_pair(app, record, runtimes, &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; + } else { + // No tracked pair here — a pubkey-wide cache clear would + // disturb live pairs in other communities, so stay scoped. + super::terminate_untracked_pair_runtime(app, &pair_key)?; + stop_legacy_scalar_pid(app, record)?; + } state.clear_agent_session_cache(&pair_key); - } + Ok(()) + })?, None => { stop_legacy_scalar_pid(app, record)?; state.clear_agent_session_caches(&record.pubkey); @@ -250,4 +252,151 @@ mod tests { selected.sort_by(|left, right| left.relay_url.cmp(&right.relay_url)); assert_eq!(selected, vec![first, second]); } + + #[cfg(unix)] + fn local_stop_refuses_ambiguous_receipt_before_side_effects(tracked: bool) { + use tauri::Manager as _; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let old_home = std::env::var_os("HOME"); + let old_xdg = std::env::var_os("XDG_DATA_HOME"); + struct RestoreEnv(Option, Option); + impl Drop for RestoreEnv { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.1.take() { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + } + let _restore_env = RestoreEnv(old_home, old_xdg); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + + let requested_relay = "wss://relay.example/room/"; + let stored_relay = "wss://relay.example/room"; + let pubkey = "aa".repeat(32); + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(requested_relay.into()); + let app = tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let instance_id = super::super::current_instance_id(app.handle()); + + let mut child = + Some(super::super::test_fixtures::MarkedTestChild::spawn(&instance_id).unwrap()); + let pid = child.as_ref().unwrap().id(); + let _process_guard = super::super::test_fixtures::MarkedProcessGuard::new(pid); + assert!(super::super::process_has_buzz_marker(pid, &instance_id)); + + let stored_key = ManagedAgentRuntimeKey::new(&pubkey, stored_relay).unwrap(); + let requested_key = ManagedAgentRuntimeKey::new(&pubkey, requested_relay).unwrap(); + let receipt = super::super::super::ManagedAgentRuntimeReceipt { + authority_version: 0, + key: stored_key.clone(), + pid, + desktop_instance_id: instance_id, + started_at: "now".into(), + }; + super::super::super::write_agent_runtime_receipt(app.handle(), &receipt).unwrap(); + + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": pubkey, + "name": "test", + "private_key_nsec": "", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "before" + })) + .unwrap(); + let mut runtimes = HashMap::new(); + if tracked { + let process = crate::managed_agents::ManagedAgentProcess { + child: child.take().unwrap().into_child(), + log_path: Default::default(), + spawn_config: + crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + &record, + &[], + &[], + requested_relay, + &Default::default(), + false, + crate::managed_agents::AcpSessionPolicy::Channel, + ), + setup_mode: false, + adapter_availability: None, + start_nonce: "test-nonce".into(), + }; + runtimes.insert( + requested_key.clone(), + ManagedAgentPairRuntime::starting(process), + ); + } + + let cache: crate::managed_agents::config_bridge::SessionConfigCache = + serde_json::from_value(serde_json::json!({ + "configOptions": [], + "availableModes": [], + "availableModels": [], + "currentModel": null, + "modelOverridden": false, + "gooseNativeConfig": null, + "capturedAt": "now" + })) + .unwrap(); + app.state::() + .put_session_cache(requested_key.clone(), cache); + + let error = stop_managed_agent_workspace_pair(app.handle(), &mut record, &mut runtimes) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); + assert_eq!(record.updated_at, "before"); + assert!(record.last_stopped_at.is_none()); + assert!(app + .state::() + .get_session_cache(&requested_key) + .is_some()); + assert!( + super::super::super::read_all_agent_runtime_receipts(app.handle()) + .iter() + .any(|(_, candidate)| candidate == &receipt) + ); + + if tracked { + let runtime = runtimes.get_mut(&requested_key).unwrap(); + assert!(runtime.child.try_wait().unwrap().is_none()); + let mut runtime = runtimes.remove(&requested_key).unwrap(); + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } else { + let child = child.as_mut().unwrap(); + assert!(child.child_mut().try_wait().unwrap().is_none()); + } + super::super::super::remove_agent_runtime_receipt(app.handle(), &stored_key); + } + + #[cfg(unix)] + #[test] + fn tracked_local_stop_has_no_side_effect_before_ambiguous_receipt_refusal() { + local_stop_refuses_ambiguous_receipt_before_side_effects(true); + } + + #[cfg(unix)] + #[test] + fn untracked_local_stop_has_no_side_effect_before_ambiguous_receipt_refusal() { + local_stop_refuses_ambiguous_receipt_before_side_effects(false); + } } 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 05e11fc4cdf..841eb1ae647 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -1,5 +1,128 @@ use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; +#[cfg(unix)] +const MARKED_CHILD_FIXTURE_ENV: &str = "BUZZ_TEST_MARKED_CHILD_FIXTURE"; +#[cfg(unix)] +const MARKED_CHILD_READY_ENV: &str = "BUZZ_TEST_MARKED_CHILD_READY"; + +/// Test-executable child whose environment is stable and directly observable +/// through the production process-marker reader. +#[cfg(unix)] +pub(in crate::managed_agents) struct MarkedTestChild { + child: Option, + _ready_dir: tempfile::TempDir, +} + +#[cfg(unix)] +impl MarkedTestChild { + pub(in crate::managed_agents) fn spawn(instance_id: &str) -> Result { + use std::os::unix::process::CommandExt as _; + use std::process::{Command, Stdio}; + + let ready_dir = tempfile::tempdir().map_err(|error| error.to_string())?; + let ready_path = ready_dir.path().join("ready"); + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let mut child = Command::new(executable) + .args([ + "--exact", + "managed_agents::runtime::test_fixtures::marked_child_process_fixture", + "--nocapture", + ]) + .env_clear() + .env(MARKED_CHILD_FIXTURE_ENV, "1") + .env(MARKED_CHILD_READY_ENV, &ready_path) + .env("BUZZ_MANAGED_AGENT", instance_id) + .process_group(0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| error.to_string())?; + + for _ in 0..100 { + if ready_path.is_file() { + return Ok(Self { + child: Some(child), + _ready_dir: ready_dir, + }); + } + match child.try_wait() { + Ok(Some(status)) => { + return Err(format!( + "marked child fixture exited before readiness: {status}" + )); + } + Ok(None) => {} + Err(error) => { + let _ = super::terminate_process(child.id()); + let _ = child.wait(); + return Err(format!("failed to inspect marked child fixture: {error}")); + } + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let _ = super::terminate_process(child.id()); + let _ = child.wait(); + Err("marked child fixture did not become ready".into()) + } + + pub(in crate::managed_agents) fn id(&self) -> u32 { + self.child.as_ref().expect("child is present").id() + } + + pub(in crate::managed_agents) fn child_mut(&mut self) -> &mut std::process::Child { + self.child.as_mut().expect("child is present") + } + + pub(in crate::managed_agents) fn into_child(mut self) -> std::process::Child { + self.child.take().expect("child is present") + } +} + +#[cfg(unix)] +impl Drop for MarkedTestChild { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = super::terminate_process(child.id()); + let _ = child.wait(); + } + } +} + +/// Backstop for children whose owned `Child` handle is moved into production +/// runtime state. A failed assertion still terminates the complete process +/// group; successful tests explicitly wait through the owned handle. +#[cfg(unix)] +pub(in crate::managed_agents) struct MarkedProcessGuard(u32); + +#[cfg(unix)] +impl MarkedProcessGuard { + pub(in crate::managed_agents) fn new(pid: u32) -> Self { + Self(pid) + } +} + +#[cfg(unix)] +impl Drop for MarkedProcessGuard { + fn drop(&mut self) { + let _ = super::terminate_process(self.0); + } +} + +#[cfg(unix)] +#[test] +fn marked_child_process_fixture() { + if std::env::var_os(MARKED_CHILD_FIXTURE_ENV).is_none() { + return; + } + let ready_path = std::env::var_os(MARKED_CHILD_READY_ENV) + .expect("marked child fixture requires a readiness path"); + std::fs::write(ready_path, b"ready").expect("write marked child readiness handshake"); + loop { + std::thread::park_timeout(std::time::Duration::from_secs(60)); + } +} + pub(super) const EXPECTED_ACCESS_ENV: &str = "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"; pub(super) fn expected_owner_only() -> bool { @@ -30,7 +153,7 @@ pub(super) fn expected_mode(oss_mode: &'static str) -> &'static str { } /// Construct a minimal record fixture for runtime tests. -pub(super) fn fixture( +pub(in crate::managed_agents) fn fixture( respond_to: RespondTo, allowlist: Vec, auth_tag: Option, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 57521c04fff..87318f8405c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,8 @@ use crate::managed_agents::known_acp_runtime; +#[path = "authority_tests.rs"] +mod authority_tests; + #[path = "cli_tests.rs"] mod cli_tests; @@ -860,6 +863,7 @@ fn receipt_fixture( key: crate::managed_agents::ManagedAgentRuntimeKey, ) -> crate::managed_agents::ManagedAgentRuntimeReceipt { crate::managed_agents::ManagedAgentRuntimeReceipt { + authority_version: crate::managed_agents::RUNTIME_AUTHORITY_RECEIPT_VERSION, key, pid: std::process::id(), desktop_instance_id: "test-instance".into(), @@ -895,64 +899,6 @@ fn receipt_validation_rejects_wrong_pair_filename() { )); } -#[test] -fn replacement_removes_receipt_only_after_confirmed_exit() { - use std::cell::{Cell, RefCell}; - - let receipt = receipt_fixture( - crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") - .unwrap(), - ); - let path = std::path::Path::new("pair.json"); - let terminated = Cell::new(None); - let polls = Cell::new(0); - let removed = RefCell::new(None); - - super::terminate_runtime_receipt_with( - path, - &receipt, - |pid| { - terminated.set(Some(pid)); - Ok(()) - }, - |_| { - let poll = polls.get() + 1; - polls.set(poll); - poll < 2 - }, - |path| *removed.borrow_mut() = Some(path.to_path_buf()), - ) - .unwrap(); - - assert_eq!(terminated.get(), Some(receipt.pid)); - assert_eq!(polls.get(), 2); - assert_eq!(removed.into_inner().as_deref(), Some(path)); -} - -#[test] -fn replacement_failure_keeps_receipt() { - use std::cell::Cell; - - let receipt = receipt_fixture( - crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") - .unwrap(), - ); - let removed = Cell::new(false); - let error = super::terminate_runtime_receipt_with( - std::path::Path::new("pair.json"), - &receipt, - |_| Err("signal failed".into()), - |_| false, - |_| removed.set(true), - ) - .unwrap_err(); - - assert_eq!(error, "signal failed"); - assert!(!removed.get()); -} - -// ── workspace pair-key resolution (summary/stop scoping) ──────────────── - #[test] fn unpinned_record_resolves_pair_key_per_workspace() { // Community-scoped truth: an unpinned agent running only on relay A must @@ -967,6 +913,17 @@ fn unpinned_record_resolves_pair_key_per_workspace() { assert!(!runtimes.contains_key(&key_b)); } +#[test] +fn workspace_pair_resolution_distinguishes_loopback_communities() { + let pubkey = "aa".repeat(32); + let localhost = super::resolve_workspace_pair_key(&pubkey, "", "ws://localhost:3000").unwrap(); + let numeric = super::resolve_workspace_pair_key(&pubkey, "", "ws://127.0.0.1:3000").unwrap(); + + let runtimes = std::collections::HashMap::from([(localhost.clone(), ())]); + assert!(runtimes.contains_key(&localhost)); + assert!(!runtimes.contains_key(&numeric)); +} + #[test] fn stored_relay_pin_is_ignored_in_pair_key_resolution() { // Legacy pins are ignored (#2122): a record carrying a creation-era diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index ca7e7cc21a3..7bc12341b2f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -14,8 +14,8 @@ use crate::app_state::AppState; const STATUS_EVENT: &str = "managed-agent-runtime-status"; -fn status_for( - app: &AppHandle, +fn status_for( + app: &AppHandle, record: &super::ManagedAgentRecord, key: &ManagedAgentRuntimeKey, runtime: Option<&ManagedAgentPairRuntime>, @@ -43,8 +43,8 @@ struct StatusInputs<'a> { global: &'a super::GlobalAgentConfig, } -fn status_for_with( - app: &AppHandle, +fn status_for_with( + app: &AppHandle, record: &super::ManagedAgentRecord, key: &ManagedAgentRuntimeKey, runtime: Option<&ManagedAgentPairRuntime>, @@ -72,7 +72,7 @@ fn status_for_with( } } -fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { +fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { let _ = app.emit(STATUS_EVENT, status); } @@ -339,12 +339,12 @@ pub(crate) fn start_pair_locked( broker, )?; 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(), - }; + let receipt = ManagedAgentRuntimeReceipt::new( + key.clone(), + process.child.id(), + current_instance_id(&app), + now.clone(), + ); if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { let _ = terminate_process(process.child.id()); let _ = process.child.wait(); @@ -379,10 +379,10 @@ pub fn stop_managed_agent_runtime( } // Caller owns managed_agent_runtime_transition for the whole admission/effect. -pub(crate) fn stop_pair_locked( +pub(crate) fn stop_pair_locked( pubkey: String, relay_url: String, - app: AppHandle, + app: AppHandle, ) -> Result { let state = app.state::(); let _store = state @@ -396,30 +396,36 @@ pub(crate) fn stop_pair_locked( .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - if runtimes.contains_key(&key) { - // Use ordinary Desktop Stop, including its platform-specific child/job - // ownership. Remote control must not grow a second teardown contract. - super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?; - } else { - terminate_untracked_pair_runtime(&app, &key)?; - } - // Old scalar records have no community-bound receipt. Do not erase a live - // child or claim success for it when this request cannot establish scope. - reject_unscoped_live_child( - record.runtime_pid.filter(|pid| process_is_running(*pid)), - runtimes.values().map(|runtime| runtime.child.id()), - )?; - super::remove_agent_runtime_receipt(&app, &key); - state.clear_agent_session_cache(&key); - if record - .runtime_pid - .is_some_and(|pid| !process_is_running(pid)) - { - 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); + // V0 receipt normalization was lossy. Wrap every tracked/untracked Stop + // side effect so an ambiguous receipt cannot be killed, deleted, cleared + // from cache, persisted as stopped, or reported stopped. + let status = super::with_pair_runtime_receipt_authority(&app, &key, || { + if runtimes.contains_key(&key) { + // Use ordinary Desktop Stop, including its platform-specific + // child/job ownership. Remote control must not grow a second + // teardown contract. + super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?; + } else { + terminate_untracked_pair_runtime(&app, &key)?; + } + // Old scalar records have no community-bound receipt. Do not erase a + // live child or claim success when this request cannot establish scope. + reject_unscoped_live_child( + record.runtime_pid.filter(|pid| process_is_running(*pid)), + runtimes.values().map(|runtime| runtime.child.id()), + )?; + super::remove_agent_runtime_receipt(&app, &key); + state.clear_agent_session_cache(&key); + if record + .runtime_pid + .is_some_and(|pid| !process_is_running(pid)) + { + record.runtime_pid = None; + } + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + Ok(status_for(&app, record, &key, None, None)) + })?; drop(runtimes); save_managed_agents(&app, &records)?; emit_status(&app, &status); @@ -753,6 +759,24 @@ mod tests { assert_ne!(key, observer_lifecycle_key(&other.pubkey, &other).unwrap()); } + #[test] + fn observer_lifecycle_key_does_not_cross_loopback_communities() { + let localhost = payload( + "ws://localhost:3000", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + let numeric = payload( + "ws://127.0.0.1:3000", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + assert_ne!( + observer_lifecycle_key(&localhost.pubkey, &localhost).unwrap(), + observer_lifecycle_key(&numeric.pubkey, &numeric).unwrap() + ); + } + #[test] fn observer_lifecycle_rejects_cross_agent_and_desktop_states() { let ready = payload( @@ -792,6 +816,147 @@ mod tests { mod stop_scope_tests { use super::reject_unscoped_live_child; + #[cfg(unix)] + fn assert_remote_stop_effect_is_blocked(tracked: bool) { + use tauri::Manager as _; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let old_home = std::env::var_os("HOME"); + let old_xdg = std::env::var_os("XDG_DATA_HOME"); + struct RestoreEnv(Option, Option); + impl Drop for RestoreEnv { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.1.take() { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + } + let _restore_env = RestoreEnv(old_home, old_xdg); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + + let requested_relay = "wss://relay.example/room/"; + let stored_relay = "wss://relay.example/room"; + let pubkey = "aa".repeat(32); + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let instance_id = super::super::current_instance_id(app.handle()); + let mut child = Some( + super::super::runtime::test_fixtures::MarkedTestChild::spawn(&instance_id).unwrap(), + ); + let pid = child.as_ref().unwrap().id(); + let _process_guard = super::super::runtime::test_fixtures::MarkedProcessGuard::new(pid); + assert!(super::super::process_has_buzz_marker(pid, &instance_id)); + + let mut record = super::super::runtime::test_fixtures::fixture( + super::super::RespondTo::OwnerOnly, + Vec::new(), + None, + ); + record.pubkey = pubkey.clone(); + record.updated_at = "before".into(); + super::super::save_managed_agents(app.handle(), &[record.clone()]).unwrap(); + + let stored_key = super::ManagedAgentRuntimeKey::new(&pubkey, stored_relay).unwrap(); + let requested_key = super::ManagedAgentRuntimeKey::new(&pubkey, requested_relay).unwrap(); + let receipt = super::ManagedAgentRuntimeReceipt { + authority_version: 0, + key: stored_key.clone(), + pid, + desktop_instance_id: instance_id, + started_at: "now".into(), + }; + super::super::write_agent_runtime_receipt(app.handle(), &receipt).unwrap(); + + if tracked { + let process = crate::managed_agents::ManagedAgentProcess { + child: child.take().unwrap().into_child(), + log_path: Default::default(), + spawn_config: + crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + &record, + &[], + &[], + requested_relay, + &Default::default(), + false, + crate::managed_agents::AcpSessionPolicy::Channel, + ), + setup_mode: false, + adapter_availability: None, + start_nonce: "test-nonce".into(), + }; + app.state::() + .managed_agent_processes + .lock() + .unwrap() + .insert( + requested_key.clone(), + super::ManagedAgentPairRuntime::starting(process), + ); + } + + let cache: crate::managed_agents::config_bridge::SessionConfigCache = + serde_json::from_value(serde_json::json!({ + "configOptions": [], + "availableModes": [], + "availableModels": [], + "currentModel": null, + "modelOverridden": false, + "gooseNativeConfig": null, + "capturedAt": "now" + })) + .unwrap(); + app.state::() + .put_session_cache(requested_key.clone(), cache); + + let error = + super::stop_pair_locked(pubkey.clone(), requested_relay.into(), app.handle().clone()) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); + assert_eq!( + super::super::load_managed_agents(app.handle()).unwrap()[0].updated_at, + "before" + ); + assert!(app + .state::() + .get_session_cache(&requested_key) + .is_some()); + assert!(super::super::read_all_agent_runtime_receipts(app.handle()) + .iter() + .any(|(_, candidate)| candidate == &receipt)); + + if tracked { + let mut runtime = app + .state::() + .managed_agent_processes + .lock() + .unwrap() + .remove(&requested_key) + .unwrap(); + assert!(runtime.child.try_wait().unwrap().is_none()); + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } else { + assert!(child + .as_mut() + .unwrap() + .child_mut() + .try_wait() + .unwrap() + .is_none()); + } + super::super::remove_agent_runtime_receipt(app.handle(), &stored_key); + } + #[test] fn live_legacy_child_cannot_be_erased_or_reported_stopped() { assert!(reject_unscoped_live_child(Some(12), [].into_iter()).is_err()); @@ -799,4 +964,16 @@ mod stop_scope_tests { assert!(reject_unscoped_live_child(Some(12), [12].into_iter()).is_ok()); assert!(reject_unscoped_live_child(None, [13].into_iter()).is_ok()); } + + #[cfg(unix)] + #[test] + fn tracked_remote_stop_has_no_effect_before_ambiguous_receipt_refusal() { + assert_remote_stop_effect_is_blocked(true); + } + + #[cfg(unix)] + #[test] + fn untracked_remote_stop_has_no_effect_before_ambiguous_receipt_refusal() { + assert_remote_stop_effect_is_blocked(false); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs index 4862cedbae3..2351e60c6f0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_types.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -1,8 +1,67 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; +use url::{Host, Url}; use super::ManagedAgentProcess; +pub(crate) const RUNTIME_AUTHORITY_RECEIPT_VERSION: u8 = 1; + +/// Canonicalize only URL syntax that cannot distinguish relay authorities. +/// +/// In particular, loopback host spellings stay literal: relay tenancy and +/// lifecycle fences distinguish `localhost`, `127.*`, and `::1`. The shared +/// buzz-core normalizer predates that boundary and deliberately remains in use +/// by Bestie and migration consumers whose compatibility rules differ. +fn normalize_runtime_relay_url(raw: &str) -> Result { + let mut url = Url::parse(raw.trim()).map_err(|error| format!("invalid relay URL: {error}"))?; + if !matches!(url.scheme(), "ws" | "wss") { + return Err("relay URL scheme must be ws or wss".into()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("relay URL must not contain credentials".into()); + } + if url.fragment().is_some() { + return Err("relay URL must not contain a fragment".into()); + } + + let host = url + .host() + .ok_or_else(|| "relay URL must contain a host".to_string())?; + if let Host::Domain(domain) = host { + let lowercase = domain.to_ascii_lowercase(); + url.set_host(Some(&lowercase)) + .map_err(|_| "relay URL must contain a host".to_string())?; + } + + let default_port = match url.scheme() { + "ws" => Some(80), + "wss" => Some(443), + _ => None, + }; + if url.port() == default_port { + url.set_port(None) + .map_err(|_| "relay URL scheme must be ws or wss".to_string())?; + } + let host = match url + .host() + .ok_or_else(|| "relay URL must contain a host".to_string())? + { + Host::Domain(domain) => domain.to_string(), + Host::Ipv4(address) => address.to_string(), + Host::Ipv6(address) => format!("[{address}]"), + }; + let port = url + .port() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + let path = if url.path() == "/" { "" } else { url.path() }; + let query = url + .query() + .map(|query| format!("?{query}")) + .unwrap_or_default(); + Ok(format!("{}://{host}{port}{path}{query}", url.scheme())) +} + /// Canonical identity of one managed-agent harness on one relay. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] @@ -19,8 +78,7 @@ impl ManagedAgentRuntimeKey { } Ok(Self { pubkey: pubkey.to_ascii_lowercase(), - relay_url: buzz_core_pkg::relay::normalize_relay_url(relay_url) - .map_err(|error| error.to_string())?, + relay_url: normalize_runtime_relay_url(relay_url)?, }) } @@ -113,8 +171,106 @@ pub struct ManagedAgentCommunityTarget { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ManagedAgentRuntimeReceipt { + /// Version 0 is an unversioned legacy receipt. Its lossy host/path rendering + /// cannot prove pair authority; it is usable only for instance-wide cleanup. + #[serde(default)] + pub authority_version: u8, pub key: ManagedAgentRuntimeKey, pub pid: u32, pub desktop_instance_id: String, pub started_at: String, } + +impl ManagedAgentRuntimeReceipt { + pub(crate) fn new( + key: ManagedAgentRuntimeKey, + pid: u32, + desktop_instance_id: String, + started_at: String, + ) -> Self { + Self { + authority_version: RUNTIME_AUTHORITY_RECEIPT_VERSION, + key, + pid, + desktop_instance_id, + started_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(relay_url: &str) -> ManagedAgentRuntimeKey { + ManagedAgentRuntimeKey::new("aa".repeat(32), relay_url).unwrap() + } + + #[test] + fn runtime_identity_preserves_distinct_loopback_authorities() { + let localhost = key("ws://localhost:3000"); + let ipv4 = key("ws://127.0.0.1:3000"); + let other_ipv4 = key("ws://127.0.0.2:3000"); + let ipv6 = key("ws://[::1]:3000"); + + assert_eq!(localhost.relay_url, "ws://localhost:3000"); + assert_eq!(ipv4.relay_url, "ws://127.0.0.1:3000"); + assert_eq!(other_ipv4.relay_url, "ws://127.0.0.2:3000"); + assert_eq!(ipv6.relay_url, "ws://[::1]:3000"); + assert_ne!(localhost, ipv4); + assert_ne!(ipv4, other_ipv4); + assert_ne!(ipv4, ipv6); + } + + #[test] + fn runtime_identity_preserves_paths_queries_and_meaningful_trailing_slashes() { + assert_eq!( + key(" WSS://Relay.Example:443/community/?mode=one ").relay_url, + "wss://relay.example/community/?mode=one" + ); + assert_ne!( + key("wss://relay.example/community").relay_url, + key("wss://relay.example/community/").relay_url + ); + assert_eq!( + key("wss://relay.example/?").relay_url, + "wss://relay.example?" + ); + assert_ne!( + key("wss://relay.example").relay_url, + key("wss://relay.example/?").relay_url + ); + } + + #[test] + fn runtime_identity_rejects_non_websocket_credentials_and_fragments() { + for relay_url in [ + "https://relay.example", + "wss://user@relay.example", + "wss://relay.example/#", + "wss://relay.example/#fragment", + ] { + assert!(ManagedAgentRuntimeKey::new("aa".repeat(32), relay_url).is_err()); + } + } + + #[test] + fn receipt_authority_version_distinguishes_new_and_unversioned_receipts() { + let receipt = ManagedAgentRuntimeReceipt::new( + key("ws://localhost:3000"), + 42, + "instance".into(), + "now".into(), + ); + assert_eq!(receipt.authority_version, RUNTIME_AUTHORITY_RECEIPT_VERSION); + + let legacy: ManagedAgentRuntimeReceipt = serde_json::from_value(serde_json::json!({ + "key": receipt.key, + "pid": 42, + "desktopInstanceId": "instance", + "startedAt": "now" + })) + .unwrap(); + assert_eq!(legacy.authority_version, 0); + } +} diff --git a/desktop/src-tauri/src/managed_agents/session_policy.rs b/desktop/src-tauri/src/managed_agents/session_policy.rs index eb723908cab..63d271a0b6a 100644 --- a/desktop/src-tauri/src/managed_agents/session_policy.rs +++ b/desktop/src-tauri/src/managed_agents/session_policy.rs @@ -79,8 +79,8 @@ pub(crate) fn apply_acp_session_policy_env( /// Resolve the effective policy, apply it to `command`, and return it so the /// caller can stamp the same value onto the spawn snapshot (env and badge can /// never disagree about what the child launched with). -pub(crate) fn apply_app_acp_session_policy_env( - app: &AppHandle, +pub(crate) fn apply_app_acp_session_policy_env( + app: &AppHandle, command: &mut std::process::Command, ) -> AcpSessionPolicy { let policy = acp_session_policy(app.state::().inner()); diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index f8a2c1039a8..2eb01b5ed33 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -48,7 +48,7 @@ pub(crate) fn managed_agents_store_path( Ok(managed_agents_base_dir(app)?.join("managed-agents.json")) } -fn managed_agents_logs_dir(app: &AppHandle) -> Result { +fn managed_agents_logs_dir(app: &AppHandle) -> Result { let dir = managed_agents_base_dir(app)?.join("logs"); fs::create_dir_all(&dir).map_err(|error| format!("failed to create logs dir: {error}"))?; Ok(dir) @@ -88,8 +88,8 @@ pub fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result( + app: &AppHandle, key: &ManagedAgentRuntimeKey, ) -> Result { Ok(managed_agents_logs_dir(app)?.join(format!("{}.log", key.runtime_id()))) @@ -813,8 +813,8 @@ fn agent_pids_dir(app: &AppHandle) -> Result( + app: &AppHandle, receipt: &ManagedAgentRuntimeReceipt, ) -> Result<(), String> { let path = agent_pids_dir(app)?.join(format!("{}.json", receipt.key.runtime_id())); @@ -836,8 +836,8 @@ pub fn remove_agent_runtime_receipt_path(path: &Path) { let _ = fs::remove_file(path); } -pub fn read_all_agent_runtime_receipts( - app: &AppHandle, +pub fn read_all_agent_runtime_receipts( + app: &AppHandle, ) -> Vec<(PathBuf, ManagedAgentRuntimeReceipt)> { let Ok(dir) = agent_pids_dir(app) else { return Vec::new(); diff --git a/desktop/src-tauri/src/relay/scope.rs b/desktop/src-tauri/src/relay/scope.rs index baaee4df8ab..fa6307ec8fb 100644 --- a/desktop/src-tauri/src/relay/scope.rs +++ b/desktop/src-tauri/src/relay/scope.rs @@ -132,7 +132,7 @@ mod tests { let runtime = crate::managed_agents::ManagedAgentRuntimeKey::new("a".repeat(64), captured.as_str()) .unwrap(); - assert_eq!(runtime.relay_url, "ws://127.0.0.1:3037"); + assert_eq!(runtime.relay_url, relay); assert_eq!(captured.revalidate(relay.into()).unwrap().as_str(), relay); } diff --git a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs index 21327c30d92..c19fb1f3792 100644 --- a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs +++ b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs @@ -21,7 +21,7 @@ test("canonicalCommunityRelays dedupes by canonical form, keeps stored spelling" const relays = canonicalCommunityRelays( [ { relayUrl: "ws://localhost:3000" }, - // Same relay, different spelling — folds onto the first entry. + // A distinct loopback authority is a distinct community. { relayUrl: "ws://127.0.0.1:3000" }, { relayUrl: "wss://relay.example" }, // Unparsable entries are dropped rather than reconciled. @@ -32,7 +32,8 @@ test("canonicalCommunityRelays dedupes by canonical form, keeps stored spelling" assert.deepEqual( [...relays.entries()], [ - ["ws://127.0.0.1:3000", "ws://localhost:3000"], + ["ws://localhost:3000", "ws://localhost:3000"], + ["ws://127.0.0.1:3000", "ws://127.0.0.1:3000"], ["wss://relay.example", "wss://relay.example"], ], ); @@ -40,7 +41,8 @@ test("canonicalCommunityRelays dedupes by canonical form, keeps stored spelling" test("pendingReconcileRelays skips reconciled and in-flight relays", () => { const canonicalToRequested = new Map([ - ["ws://127.0.0.1:3000", "ws://localhost:3000"], + ["ws://localhost:3000", "ws://localhost:3000"], + ["ws://127.0.0.1:3000", "ws://127.0.0.1:3000"], ["wss://a.example", "wss://a.example"], ["wss://b.example", "wss://b.example"], ]); @@ -49,7 +51,7 @@ test("pendingReconcileRelays skips reconciled and in-flight relays", () => { new Set(["wss://a.example"]), new Set(["ws://127.0.0.1:3000"]), ); - assert.deepEqual(pending, ["wss://b.example"]); + assert.deepEqual(pending, ["ws://localhost:3000", "wss://b.example"]); }); test("classifyReconcileResult marks the whole batch failed when the call throws", () => { @@ -64,7 +66,7 @@ test("classifyReconcileResult marks the whole batch failed when the call throws" }); test("classifyReconcileResult splits by Failed rows, matching on requested URL", () => { - const attempted = ["ws://127.0.0.1:3000", "wss://b.example"]; + const attempted = ["ws://localhost:3000", "wss://b.example"]; const rows = [ // Started cleanly on the loopback relay — reconciled. { @@ -92,7 +94,7 @@ test("classifyReconcileResult splits by Failed rows, matching on requested URL", assert.deepEqual( classifyReconcileResult(attempted, rows, canonicalRelayUrl), { - succeeded: ["ws://127.0.0.1:3000"], + succeeded: ["ws://localhost:3000"], failed: ["wss://b.example"], }, ); diff --git a/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs b/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs index 4ec07c91706..e15aa93a07d 100644 --- a/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs @@ -47,3 +47,16 @@ test("startup reconcile preserves unrelated runtime rows", () => { [discovered, existing], ); }); + +test("startup reconcile keeps same-agent loopback authority rows distinct", () => { + const localhost = runtime({ relayUrl: "ws://localhost:3000" }); + const numeric = runtime({ + relayUrl: "ws://127.0.0.1:3000", + lifecycle: "ready", + }); + + assert.deepEqual( + mergeManagedAgentRuntimeStatuses([localhost], [localhost], [numeric]), + [numeric, localhost], + ); +}); diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs index edd368eccd6..0b14bf9b4ab 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { agentCommunityAvailability, agentCommunityStatusDetail, + canonicalBestieRelayUrl, canonicalRelayUrl, findManagedAgentRuntime, managedAgentRuntimeKey, @@ -78,9 +79,8 @@ test("selects one relay without collapsing same-pubkey pairs", () => { }); test("canonicalRelayUrl mirrors the backend pair-key normalization", () => { - // Loopback folding + default-port and trailing-slash stripping — the - // standard dev setup that previously broke pair matching. - assert.equal(canonicalRelayUrl("ws://localhost:3000"), "ws://127.0.0.1:3000"); + assert.equal(canonicalRelayUrl("ws://localhost:3000"), "ws://localhost:3000"); + assert.equal(canonicalRelayUrl("ws://127.0.0.1:3000"), "ws://127.0.0.1:3000"); assert.equal( canonicalRelayUrl("WSS://Relay.Example:443/"), "wss://relay.example", @@ -91,23 +91,57 @@ test("canonicalRelayUrl mirrors the backend pair-key normalization", () => { ); assert.equal( canonicalRelayUrl("wss://relay.example/path/"), - "wss://relay.example/path", + "wss://relay.example/path/", + ); + assert.equal(canonicalRelayUrl("ws://[::1]:3000"), "ws://[::1]:3000"); + assert.equal( + canonicalRelayUrl("wss://relay.example/community/?mode=one"), + "wss://relay.example/community/?mode=one", + ); + assert.equal( + canonicalRelayUrl("wss://relay.example/?"), + "wss://relay.example?", + ); + assert.notEqual( + canonicalRelayUrl("wss://relay.example/?"), + canonicalRelayUrl("wss://relay.example"), ); - assert.equal(canonicalRelayUrl("ws://[::1]:3000"), "ws://127.0.0.1:3000"); assert.equal(canonicalRelayUrl("https://relay.example"), null); + assert.equal(canonicalRelayUrl("wss://user@relay.example"), null); + assert.equal(canonicalRelayUrl("wss://relay.example/#"), null); + assert.equal(canonicalRelayUrl("wss://relay.example/#fragment"), null); assert.equal(canonicalRelayUrl("not a url"), null); }); -test("matches a stored community URL against canonical backend rows", () => { +test("Bestie retains its Rust legacy equivalence without widening runtime identity", () => { + assert.equal( + canonicalBestieRelayUrl("ws://localhost:3000"), + "ws://127.0.0.1:3000", + ); + assert.equal( + canonicalBestieRelayUrl("wss://relay.example/path/"), + "wss://relay.example/path", + ); + assert.equal( + canonicalBestieRelayUrl("wss://relay.example/?mode=one"), + "wss://relay.example/?mode=one", + ); + assert.equal( + canonicalBestieRelayUrl("wss://relay.example/?"), + "wss://relay.example/?", + ); +}); + +test("runtime lookup never crosses loopback community authorities", () => { const runtimes = [ runtime({ relayUrl: "ws://127.0.0.1:3000", lifecycle: "ready" }), ]; assert.equal( - findManagedAgentRuntime(runtimes, "aa", "ws://localhost:3000")?.lifecycle, - "ready", + findManagedAgentRuntime(runtimes, "aa", "ws://localhost:3000"), + undefined, ); assert.equal( - findManagedAgentRuntime(runtimes, "aa", "ws://localhost:3001"), - undefined, + findManagedAgentRuntime(runtimes, "aa", "ws://127.0.0.1:3000"), + runtimes[0], ); }); diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index c3a952f7d5d..1aa9a395eeb 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -63,30 +63,54 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record< }; /** - * Canonicalize a relay URL the way the backend keys runtime pairs, so a - * stored community URL (e.g. `ws://localhost:3000`) matches backend rows - * (`ws://127.0.0.1:3000`). Mirrors buzz-core's `normalize_relay_url` - * (`crates/buzz-core/src/relay.rs`): lowercase host, loopback hosts folded - * to 127.0.0.1, default ports and root-path trailing slash stripped. - * Returns null when the URL cannot be parsed as ws/wss. + * Canonicalize a relay URL the way the backend keys runtime pairs. Host + * spellings remain distinct because the relay authority is the community: + * `localhost`, `127.*`, and `::1` must never select one another's process. + * DNS case/default ports and a root slash are syntax-only; non-root paths, + * queries, and meaningful trailing slashes are preserved. */ export function canonicalRelayUrl(raw: string): string | null { + const input = raw.trim(); let url: URL; try { - url = new URL(raw.trim()); + url = new URL(input); } catch { return null; } if (url.protocol !== "ws:" && url.protocol !== "wss:") return null; + if (url.username !== "" || url.password !== "" || input.includes("#")) + return null; + const host = url.hostname.toLowerCase(); + const defaultPort = url.protocol === "ws:" ? "80" : "443"; + const port = url.port && url.port !== defaultPort ? `:${url.port}` : ""; + const path = url.pathname === "/" ? "" : url.pathname; + const query = url.search || (url.href.endsWith("?") ? "?" : ""); + return `${url.protocol}//${host}${port}${path}${query}`; +} + +/** + * Bestie's Rust scope check intentionally retains buzz-core's legacy + * loopback-folding equivalence. Keep its React Query cache key aligned without + * reusing that broader equivalence for managed-runtime identity. + */ +export function canonicalBestieRelayUrl(raw: string): string | null { + const input = raw.trim(); + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") return null; + if (url.username !== "" || url.password !== "" || input.includes("#")) + return null; let host = url.hostname.toLowerCase(); if (host === "localhost" || host === "[::1]" || host.startsWith("127.")) { host = "127.0.0.1"; } - const defaultPort = url.protocol === "ws:" ? "80" : "443"; - const port = url.port && url.port !== defaultPort ? `:${url.port}` : ""; - const path = url.pathname === "/" ? "" : url.pathname; - // The backend trims trailing slashes from the final rendered URL. - return `${url.protocol}//${host}${port}${path}${url.search}`.replace( + const port = url.port ? `:${url.port}` : ""; + const query = url.search || (url.href.endsWith("?") ? "?" : ""); + return `${url.protocol}//${host}${port}${url.pathname}${query}`.replace( /\/+$/, "", ); @@ -98,10 +122,8 @@ export function findManagedAgentRuntime( relayUrl: string, ): ManagedAgentRuntimeStatus | undefined { const normalizedPubkey = pubkey.toLowerCase(); - // Backend rows carry the canonical pair URL; the caller passes the - // community's stored URL, which may differ in spelling (localhost vs - // 127.0.0.1, default port, trailing slash). Compare canonically, keeping - // the exact-string checks as a fallback for unparsable stored URLs. + // Backend rows carry the canonical pair URL; compare syntax-equivalent + // spellings while preserving distinct host authorities. const canonical = canonicalRelayUrl(relayUrl); return runtimes.find( (runtime) => diff --git a/desktop/src/protectedFeatures/bestie/useBestie.ts b/desktop/src/protectedFeatures/bestie/useBestie.ts index d724023a793..05302b9eeaa 100644 --- a/desktop/src/protectedFeatures/bestie/useBestie.ts +++ b/desktop/src/protectedFeatures/bestie/useBestie.ts @@ -8,7 +8,7 @@ import { useManagedAgentRuntimesQuery, } from "@/features/agents/managedAgentRuntimeHooks"; import { - canonicalRelayUrl, + canonicalBestieRelayUrl, findManagedAgentRuntime, managedAgentPairAction, } from "@/features/agents/managedAgentRuntimeStatus"; @@ -36,7 +36,7 @@ export function bestieAssignmentQueryKey( ) { return [ "bestie-assignment", - canonicalRelayUrl(relayUrl) ?? relayUrl, + canonicalBestieRelayUrl(relayUrl) ?? relayUrl, ownerPubkey.toLowerCase(), ] as const; } From 4ae32aa588a0d6c8b965d9b002ad3f41754b5ad8 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 7 Sep 2026 11:32:26 -0400 Subject: [PATCH 31/51] feat(desktop): checkpoint scoped runtime configuration launches Local integration checkpoint; native compilation and strict ACP model enforcement remain validation gates. Scope and async prepared-launch APIs are stable for lifecycle integration; not publication-ready. Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_lifecycle.rs | 2 + .../src/desktop_lifecycle/configuration.rs | 62 +++ .../src/commands/agent_config_tests.rs | 1 + .../src-tauri/src/commands/agent_settings.rs | 12 + desktop/src-tauri/src/commands/agents.rs | 107 +++- .../src-tauri/src/commands/agents_tests.rs | 1 + desktop/src-tauri/src/commands/mod.rs | 2 + .../commands/personas/delete_cascade_tests.rs | 1 + .../personas/inbound/inbound_tests.rs | 1 + .../personas/snapshot/fidelity_tests.rs | 1 + .../src/commands/personas/snapshot/import.rs | 1 + .../src/commands/personas/snapshot/tests.rs | 2 + .../personas/snapshot/tests_locked.rs | 1 + .../personas/update/name_propagation_tests.rs | 1 + .../src/commands/runtime_configurations.rs | 143 ++++++ .../src-tauri/src/commands/team_snapshot.rs | 1 + .../src/commands/team_snapshot/tests.rs | 1 + desktop/src-tauri/src/lib.rs | 3 + .../src/managed_agents/agent_events.rs | 1 + .../managed_agents/agent_snapshot_envelope.rs | 1 + .../managed_agents/agent_snapshot_tests.rs | 1 + .../config_bridge/effort_tests.rs | 1 + .../config_bridge/reader_tests.rs | 1 + .../src-tauri/src/managed_agents/discovery.rs | 4 + .../src/managed_agents/discovery/tests.rs | 1 + .../managed_agents/effective_config/mod.rs | 15 +- .../managed_agents/effective_config/tests.rs | 1 + .../src/managed_agents/global_config/tests.rs | 1 + desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/nest/render_tests.rs | 1 + .../src/managed_agents/parallelism.rs | 1 + .../managed_agents/persona_events/tests.rs | 1 + .../src-tauri/src/managed_agents/readiness.rs | 23 +- .../src-tauri/src/managed_agents/restore.rs | 3 +- .../src-tauri/src/managed_agents/runtime.rs | 146 +++++- .../managed_agents/runtime/authority_tests.rs | 1 + .../src/managed_agents/runtime/stop.rs | 1 + .../managed_agents/runtime/test_fixtures.rs | 1 + .../src/managed_agents/runtime/tests.rs | 1 + .../src/managed_agents/runtime_commands.rs | 63 ++- .../managed_agents/runtime_configurations.rs | 484 ++++++++++++++++++ .../runtime_configurations/store.rs | 75 +++ .../runtime_configurations/tests.rs | 380 ++++++++++++++ .../src/managed_agents/runtime_types.rs | 4 + .../src/managed_agents/spawn_snapshot.rs | 5 + .../spawn_snapshot/diff/tests.rs | 1 + .../managed_agents/spawn_snapshot/tests.rs | 1 + .../src/managed_agents/team_snapshot.rs | 1 + .../src/managed_agents/teams_tests.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 4 + desktop/src/features/agents/AGENTS.md | 24 + desktop/src/features/agents/ui/AgentsView.tsx | 2 + .../agents/ui/RuntimeConfigurations.test.mjs | 164 ++++++ .../agents/ui/RuntimeConfigurations.tsx | 388 ++++++++++++++ desktop/src/shared/api/types.ts | 7 +- docs/named-runtime-configurations.md | 54 ++ 56 files changed, 2162 insertions(+), 46 deletions(-) create mode 100644 crates/buzz-core/src/desktop_lifecycle/configuration.rs create mode 100644 desktop/src-tauri/src/commands/runtime_configurations.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime_configurations.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime_configurations/store.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs create mode 100644 desktop/src/features/agents/ui/RuntimeConfigurations.test.mjs create mode 100644 desktop/src/features/agents/ui/RuntimeConfigurations.tsx create mode 100644 docs/named-runtime-configurations.md diff --git a/crates/buzz-core/src/desktop_lifecycle.rs b/crates/buzz-core/src/desktop_lifecycle.rs index c437830f4aa..e7e54e52825 100644 --- a/crates/buzz-core/src/desktop_lifecycle.rs +++ b/crates/buzz-core/src/desktop_lifecycle.rs @@ -1,8 +1,10 @@ //! Owner-private lifecycle requests. Signed order is intent, not process state. +mod configuration; use crate::{ desktop_stop::{hex, read, sign, StopTarget}, kind::{KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT}, }; +pub use configuration::{RuntimeConfigurationRef, RuntimeConfigurationSummary}; use nostr::{Event, Keys, Tag}; use serde::{Deserialize, Serialize}; diff --git a/crates/buzz-core/src/desktop_lifecycle/configuration.rs b/crates/buzz-core/src/desktop_lifecycle/configuration.rs new file mode 100644 index 00000000000..9ba560cd734 --- /dev/null +++ b/crates/buzz-core/src/desktop_lifecycle/configuration.rs @@ -0,0 +1,62 @@ +//! Safe owner-private projections, not another configuration store or launch plan. +use serde::{Deserialize, Serialize}; + +/// Exact destination-local configuration version. Edits produce a new revision. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfigurationRef { + /// Stable configuration identity within the targeted agent and host. + pub id: String, + /// Immutable revision selected by the requester, never a latest alias. + pub revision: String, +} +impl RuntimeConfigurationRef { + /// Reject malformed references before resolving any local settings. + pub fn validate(&self) -> Result<(), String> { + if uuid::Uuid::parse_str(&self.id).is_err() + || uuid::Uuid::parse_str(&self.revision).is_err() + { + return Err("Invalid runtime configuration reference".into()); + } + Ok(()) + } +} + +/// Explicit allowlist for discovery. Never serialize the underlying settings. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfigurationSummary { + /// Exact configuration version checked by the destination. + pub configuration: RuntimeConfigurationRef, + /// User-authored display name. + pub name: String, + /// Destination Desktop ID, not a hostname or filesystem path. + pub host: String, + /// Runtime catalog identifier. + pub runtime: String, + /// Deliberately selected model identifier. + pub model: String, + /// Deliberately selected provider identifier, never credentials. + pub provider: Option, + /// Positive destination-local readiness; unknown must be false. + pub eligible: bool, +} +impl RuntimeConfigurationSummary { + /// Bound untrusted display metadata and bind it to the probed host. + pub fn validate(&self, host: &str) -> Result<(), String> { + self.configuration.validate()?; + let text = |value: &str, max| { + !value.trim().is_empty() && value.len() <= max && !value.chars().any(char::is_control) + }; + if self.host != host + || !super::hex(host, 32) + || !text(&self.name, 120) + || !text(&self.runtime, 128) + || !text(&self.model, 512) + || self.provider.as_ref().is_some_and(|p| !text(p, 128)) + { + return Err("Invalid runtime configuration summary".into()); + } + Ok(()) + } +} diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 093e925f18a..3252f9f7683 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -69,6 +69,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 1371abba2c6..25ea58db0d3 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -97,6 +97,18 @@ pub async fn set_managed_agent_auto_restart( { let record = find_managed_agent_mut(&mut records, &pubkey)?; + if auto_restart_on_config_change + && !record + .runtime_configurations + .get( + &state.signing_keys()?.public_key().to_hex(), + &crate::relay::relay_ws_url_with_override(&state), + ) + .entries + .is_empty() + { + return Err("Named runtime configurations apply on deliberate next Start".into()); + } record.auto_restart_on_config_change = auto_restart_on_config_change; record.updated_at = now_iso(); } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index af1cae86f8c..9ec2dfae44e 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -174,6 +174,9 @@ async fn start_local_agent_with_preflight( expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, replay_floor_unix: Option, + requested: Option< + Option<&crate::managed_agents::runtime_configurations::RuntimeConfigurationRef>, + >, ) -> Result { let launch_owner = workspace_owner_hex(state)?; // Runtime keys preserve the workspace host authority. Bind that same @@ -220,18 +223,47 @@ async fn start_local_agent_with_preflight( // 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(), - matches!(intent, LocalStartIntent::Create), - ) - .await?; + let selected = crate::managed_agents::runtime_configurations::selected_reference( + &record_snapshot, + Some(&launch_owner), + launch_relay.as_str(), + )?; + let configuration = requested.map(|r| r.cloned()).unwrap_or(selected); + let prepared = if requested.is_some() || configuration.is_some() { + Some( + crate::managed_agents::runtime_configurations::prepare_for_app( + app, + &record_snapshot, + configuration.as_ref(), + &launch_owner, + launch_relay.as_str(), + )?, + ) + } else { + None + }; + if let Some(plan) = &prepared { + crate::managed_agents::runtime_configurations::preflight_prepared( + app, + plan, + &launch_owner, + launch_relay.as_str(), + ) + .await?; + } else { + 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(), + matches!(intent, LocalStartIntent::Create), + ) + .await?; + } // The mesh preflight above is the suspension window Projects callbacks // capture their scope against: a community switch during that await @@ -268,6 +300,22 @@ async fn start_local_agent_with_preflight( if record.backend != BackendKind::Local { return Err(format!("agent {pubkey} is no longer a local agent")); } + if let Some(plan) = &prepared { + if requested.is_none() + && crate::managed_agents::runtime_configurations::selected_reference( + record, + Some(&launch_owner), + launch_relay.as_str(), + )? != configuration + { + return Err("Selected configuration changed during preflight".into()); + } + plan.revalidate( + record, + &load_personas(app)?, + &crate::managed_agents::load_global_agent_config(app)?, + )?; + } // 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 @@ -276,7 +324,7 @@ async fn start_local_agent_with_preflight( // 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() { + if let Some(persona_id) = record.persona_id.clone().filter(|_| prepared.is_none()) { match personas.iter().find(|p| p.id == persona_id) { Some(persona) => { crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); @@ -289,7 +337,7 @@ async fn start_local_agent_with_preflight( } } } - start_managed_agent_process( + crate::managed_agents::start_managed_agent_process_prepared( app, record, &mut runtimes, @@ -297,6 +345,7 @@ async fn start_local_agent_with_preflight( &workspace_relay_url, replay_floor_unix, resume.as_ref(), + prepared.as_ref(), )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { @@ -635,6 +684,7 @@ pub async fn create_managed_agent( linked_persona.as_ref(), )?; let record = ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: pubkey.clone(), name: name.clone(), description: None, @@ -756,6 +806,7 @@ pub async fn create_managed_agent( None, None, None, + None, ) .await { @@ -973,6 +1024,7 @@ pub async fn start_managed_agent( expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), replay_floor_unix, + None, ) .await } @@ -1223,3 +1275,30 @@ use profile::{profile_needs_sync, resolve_legacy_avatar}; #[cfg(test)] #[path = "agents_tests.rs"] mod tests; + +/// Start the explicitly reviewed revision through ordinary local async preflight. +#[tauri::command] +pub async fn start_runtime_configuration( + app: AppHandle, + owner: String, + community: String, + agent: String, + configuration: Option, +) -> Result { + let state = app.state::(); + super::desktop_profiles::scope(&app, &state, &owner, &community)?; + if !super::desktop_stop::owned_local(&app, &state, &owner, &agent)? { + return Err("Agent ownership is unavailable on this Desktop".into()); + } + start_local_agent_with_preflight( + &app, + &state, + &agent, + LocalStartIntent::Explicit, + Some(&community), + Some(&owner), + None, + Some(configuration.as_ref()), + ) + .await +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 59e04b09ff0..e58e9c8adf2 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -9,6 +9,7 @@ fn bare_agent_record( use crate::managed_agents::{BackendKind, RespondTo}; use std::collections::BTreeMap; ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a0bbdd76c91..1abb3861fd0 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -20,6 +20,8 @@ mod channels; mod clipboard; mod desktop_capabilities; mod desktop_lifecycle; +mod runtime_configurations; +pub use runtime_configurations::*; mod desktop_profiles; pub(crate) mod desktop_stop; mod dms; diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 6a10a1f9ee2..334a4f2ca0e 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -17,6 +17,7 @@ fn make_agent( runtime_pid: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: pubkey.to_string(), name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index e90df637314..d6bccb651aa 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -163,6 +163,7 @@ const AGENT_PUBKEY: &str = "agentpubkeyhex00000000000000000000000000000000000000 /// event must NEVER be able to overwrite. fn local_agent() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: AGENT_PUBKEY.to_string(), name: "Local Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 55a64db59bc..06a89099127 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), slug: Some(slug.to_string()), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 041a0b91dc9..da2d4c766ca 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -597,6 +597,7 @@ pub async fn confirm_agent_snapshot_import( // Build the managed agent record — no machine-local commands, no // secrets, no lineage from the snapshot. let record = ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index abf4bef443d..6d62ea9b24c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; /// persona_id. fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), slug: Some(slug.to_string()), @@ -84,6 +85,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { /// have `slug: None` and link to their definition via `persona_id`. fn make_instance(pubkey: &str, persona_id: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: pubkey.to_string(), slug: None, persona_id: Some(persona_id.to_string()), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs index 43ca23cc822..57a70c6799b 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -16,6 +16,7 @@ use crate::managed_agents::agent_snapshot_envelope::{ /// agent-endpoint unlock path resolves exactly as production does. fn record_for(agent: &nostr::Keys) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: agent.public_key().to_hex(), slug: None, persona_id: Some("locked-test".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index 7aedcb25ef5..080fd50188d 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -5,6 +5,7 @@ use super::*; fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: format!("pubkey-{name}"), name: name.to_string(), diff --git a/desktop/src-tauri/src/commands/runtime_configurations.rs b/desktop/src-tauri/src/commands/runtime_configurations.rs new file mode 100644 index 00000000000..9ef93a77a5f --- /dev/null +++ b/desktop/src-tauri/src/commands/runtime_configurations.rs @@ -0,0 +1,143 @@ +//! Local configuration management; no secret values or credentials cross IPC. +use super::{ + desktop_profiles::scope, + desktop_stop::{local_id, owned_local}, +}; +use crate::{ + app_state::AppState, + managed_agents::{ + self, + runtime_configurations::{self, RuntimeConfigurations}, + }, +}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeConfigurationView { + configurations: RuntimeConfigurations, + updated_at: String, + host: String, + catalog: Vec, + running: Option, +} + +fn view( + record: &managed_agents::ManagedAgentRecord, + host: String, + community: &str, + owner: &str, + app: &AppHandle, +) -> Result { + let personas = managed_agents::load_personas(app)?; + let global = managed_agents::load_global_agent_config(app)?; + let catalog = + runtime_configurations::catalog(record, &personas, &global, &host, owner, community); + let key = managed_agents::ManagedAgentRuntimeKey::new(&record.pubkey, community)?; + let state = app.state::(); + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let running = match runtimes.get_mut(&key) { + Some(runtime) => { + if runtime + .child + .try_wait() + .map_err(|_| "Running process state is unavailable")? + .is_none() + { + runtime.spawn_config.runtime_configuration.clone() + } else { + None + } + } + None => None, + }; + Ok(RuntimeConfigurationView { + configurations: record.runtime_configurations.get(owner, community), + updated_at: record.updated_at.clone(), + host, + catalog, + running, + }) +} + +#[tauri::command] +pub async fn get_runtime_configurations( + app: AppHandle, + owner: String, + community: String, + agent: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + if !owned_local(&app, &state, &owner, &agent)? { + return Err("Agent ownership is unavailable on this Desktop".into()); + } + let host = local_id( + &mut managed_agents::retention::open_retention_db(&scope.db_path)?, + &scope, + )?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = managed_agents::load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == agent) + .ok_or("Agent is unavailable")?; + view(record, host, &community, &owner, &app) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn save_runtime_configurations( + app: AppHandle, + owner: String, + community: String, + agent: String, + expected_updated_at: String, + configurations: RuntimeConfigurations, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let scope = scope(&app, &state, &owner, &community)?; + if !owned_local(&app, &state, &owner, &agent)? { + return Err("Agent ownership is unavailable on this Desktop".into()); + } + let host = local_id( + &mut managed_agents::retention::open_retention_db(&scope.db_path)?, + &scope, + )?; + configurations.validate()?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = managed_agents::load_managed_agents(&app)?; + let record = managed_agents::find_managed_agent_mut(&mut records, &agent)?; + if record.updated_at != expected_updated_at { + return Err("Agent changed; reload configurations before saving".into()); + } + record + .runtime_configurations + .replace(&owner, &community, &host, configurations)?; + record.updated_at = crate::util::now_iso(); + let result = view(record, host, &community, &owner, &app)?; + managed_agents::save_managed_agents(&app, &records)?; + let _ = app.emit("agents-data-changed", ()); + Ok(result) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 9c57ce12b53..2c6b7d24fd7 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -559,6 +559,7 @@ pub async fn confirm_team_snapshot_import( // Build the ManagedAgentRecord for this member. let record = ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 13c7f6ae810..2c8cc4a4071 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -193,6 +193,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { // Build a fake instance record tied to this team+persona. let instance = ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "a".repeat(64), name: "Alice".to_string(), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 67b82af3dc1..bb59c405c25 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -555,6 +555,9 @@ pub fn run() { prepare_desktop_profile, prepare_desktop_stop, prepare_desktop_lifecycle, + get_runtime_configurations, + start_runtime_configuration, + save_runtime_configurations, observe_desktop_placement, read_desktop_placement, receive_desktop_lifecycle, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 85f34260ce7..cae9836446c 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -164,6 +164,7 @@ mod tests { fn sample_agent() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agentpubkeyhex".to_string(), name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 131966409b0..590f5f93546 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -366,6 +366,7 @@ mod tests { /// pubkey/nsec pair matters here. fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey, name: "Locked Test".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index da881f64f5a..e628ab7d6cb 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; /// relevant to snapshot export are filled; the rest use defaults. fn minimal_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "deadbeef".to_string(), name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index 9c4568fceb4..b2879cc9351 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -28,6 +28,7 @@ fn buzz_agent() -> &'static KnownAcpRuntime { pub(super) fn record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: "test".to_string(), name: "Test Agent".to_string(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 34b4f1496f5..c4a16f22bb2 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -68,6 +68,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { fn test_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "test".to_string(), name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 531ae335ce5..5fd7d7ed987 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -285,6 +285,10 @@ pub fn try_record_agent_command( record: &crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], ) -> Result { + if let Some(config) = super::runtime_configurations::selected(record)? { + return presets::command_for_runtime_id(&config.runtime) + .ok_or_else(|| format!("DANGLING_HARNESS_ID:{}", config.runtime)); + } // Explicit pin always wins — if the user set a raw override, honour it. if let Some(pin) = record .agent_command_override diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index dc155d82f5b..78634880fe5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -212,6 +212,7 @@ fn record_with( override_cmd: Option<&str>, ) -> crate::managed_agents::types::ManagedAgentRecord { crate::managed_agents::types::ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), name: "r".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs index e079c76a1d0..14ca29e759d 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -15,6 +15,7 @@ pub enum ConfigSource { Definition, Global, InstanceLegacy, + RuntimeConfiguration, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,7 +24,7 @@ pub struct ResolvedField { pub source: ConfigSource, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveAgentConfig { pub model: ResolvedField, pub provider: ResolvedField, @@ -249,7 +250,7 @@ pub fn resolve_effective_config( definitions: &[AgentDefinition], global: &GlobalAgentConfig, ) -> EffectiveConfigResult { - match &record.persona_id { + let mut result = match &record.persona_id { Some(pid) => match definitions.iter().find(|d| d.id == *pid) { Some(def) => EffectiveConfigResult::Resolved(resolve_linked(def, global)), None => EffectiveConfigResult::OrphanedInstance { @@ -258,7 +259,17 @@ pub fn resolve_effective_config( }, }, None => EffectiveConfigResult::Resolved(resolve_definition_less(record, global)), + }; + // Persona still owns identity/instructions, not an explicitly selected runtime. + if let (EffectiveConfigResult::Resolved(config), Ok(Some(selected))) = + (&mut result, super::runtime_configurations::selected(record)) + { + config.model.value = Some(selected.model.clone()); + config.model.source = ConfigSource::RuntimeConfiguration; + config.provider.source = ConfigSource::RuntimeConfiguration; + config.provider.value = selected.provider.clone(); } + result } pub fn resolve_effective_model_provider_pair( diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 1ed44ace946..46e86ad9e89 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -41,6 +41,7 @@ fn record( ) -> ManagedAgentRecord { use crate::managed_agents::{BackendKind, RespondTo}; ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agent-pk".to_string(), name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 5f39b7b75f2..71887ba2130 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -299,6 +299,7 @@ fn default_global_config_serializes_all_fields() { fn bare_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index db259e80a71..65857b96437 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -40,6 +40,7 @@ mod restore; pub mod retention; mod runtime; mod runtime_commands; +pub(crate) mod runtime_configurations; mod runtime_types; mod session_policy; pub(crate) mod snapshot_avatar; diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index c712b2525d4..a63223568e8 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -38,6 +38,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), name: name.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index f0806c8bc04..a408ba9bb04 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -64,6 +64,7 @@ mod tests { fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), name: "r".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 9367ad463e2..8799a1ca7b1 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -5,6 +5,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// state right after creation, before any snapshot apply. pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "p".repeat(64), name: "agent".into(), diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 88cc7884c41..99043a42f3f 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -90,7 +90,7 @@ pub(crate) struct EffectiveAgentEnv { /// args, and layered env. This is the single source of truth for what will /// actually run — computed once and shared across every consumer that needs /// the effective values. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EffectiveHarnessDescriptor { /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). /// Used for `known_acp_runtime` lookup and hashing. @@ -127,6 +127,20 @@ pub(crate) fn resolve_effective_harness_descriptor( personas: &[crate::managed_agents::types::AgentDefinition], global: &crate::managed_agents::GlobalAgentConfig, ) -> Result { + // A transient projection, never persisted back onto the stable agent/persona. + let projected; + let record = if let Some(config) = super::runtime_configurations::selected(record)? { + projected = { + let mut copy = record.clone(); + copy.runtime = Some(config.runtime.clone()); + copy.agent_args.clear(); + copy.agent_command_override = None; + copy + }; + &projected + } else { + record + }; let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; let runtime_meta = known_acp_runtime(&effective_command); @@ -166,11 +180,13 @@ pub(crate) fn resolve_effective_harness_descriptor( let effective_env = resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); - Ok(EffectiveHarnessDescriptor { + let mut descriptor = EffectiveHarnessDescriptor { command: effective_command, args, env: effective_env.env, - }) + }; + super::runtime_configurations::apply_descriptor(record, &mut descriptor)?; + Ok(descriptor) } /// Assemble the effective agent env from a record, personas, optional @@ -1493,6 +1509,7 @@ mod tests { ); // Minimal record: only the fields resolve_effective_agent_env reads. let record = crate::managed_agents::types::ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "test-pubkey".to_string(), name: "test-agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 9334b0dc0dd..05e8b822e6a 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -397,12 +397,13 @@ pub async fn restore_managed_agents_on_launch( continue; }; let now = util::now_iso(); - let receipt = super::ManagedAgentRuntimeReceipt::new( + let mut receipt = super::ManagedAgentRuntimeReceipt::new( key.clone(), process.child.id(), super::current_instance_id(app), now.clone(), ); + receipt.runtime_configuration = process.spawn_config.runtime_configuration.clone(); if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { let _ = super::terminate_process(process.child.id()); let _ = process.child.wait(); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index adabff1ce16..930f640d6cc 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -332,7 +332,25 @@ pub fn build_managed_agent_summary( last_error: record.last_error.clone(), last_error_code: record.last_error_code, start_on_app_launch: record.start_on_app_launch, - auto_restart_on_config_change: record.auto_restart_on_config_change, + auto_restart_on_config_change: record.auto_restart_on_config_change + && !pair_runtime + .is_some_and(|runtime| runtime.spawn_config.runtime_configuration.is_some()) + && app + .state::() + .signing_keys() + .ok() + .is_some_and(|keys| { + record + .runtime_configurations + .get( + &keys.public_key().to_hex(), + &crate::relay::relay_ws_url_with_override( + app.state::().inner(), + ), + ) + .entries + .is_empty() + }), log_path, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), @@ -464,6 +482,7 @@ pub fn spawn_agent_child( replay_floor_unix, resume, None, + None, ) } @@ -477,6 +496,7 @@ pub(crate) fn spawn_agent_child_with_broker( replay_floor_unix: Option, resume: Option<&super::remote_stop::ResumeTicket>, broker: Option<&super::broker_launch::BrokerSession>, + prepared: Option<&super::runtime_configurations::PreparedLaunch>, ) -> Result { let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; super::remote_stop::check_launch(app, &key, relay_url, owner_hex, resume)?; @@ -512,27 +532,37 @@ pub(crate) fn spawn_agent_child_with_broker( // inherits it — no caller can bypass this by reaching `spawn_agent_child` // directly. Checked before any side effect (log marker, log file, process // spawn) so a refused spawn leaves no trace. - let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( - record, &personas, &global, - ) - .require_resolved()?; - - // Single typed resolver: validates runtime id (dangling harness → Err), resolves - // command, args (instance wins over definition default), and the full env layer stack. - // This is the sole path for harness-definition lookup — spawn, snapshot, - // summary, and model probes all consume this descriptor rather than - // assembling values inline. - // Like the orphan refusal above, this runs before any side effect so a refused - // spawn leaves no trace. - let descriptor = - crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) - .map_err(|e| { - format!( - "cannot spawn agent {}: {}", - record.pubkey, - crate::managed_agents::user_facing_harness_error(&e) - ) - })?; + let selected_ref = + super::runtime_configurations::selected_reference(record, owner_hex, relay_url)?; + let resolved; + let prepared = match prepared { + Some(plan) => { + plan.check_scope(owner_hex, relay_url)?; + plan.revalidate(record, &personas, &global)?; + Some(plan) + } + None if selected_ref.is_some() => { + resolved = super::runtime_configurations::prepare_for_app( + app, + record, + selected_ref.as_ref(), + owner_hex.ok_or("Desktop owner unavailable")?, + relay_url, + )?; + Some(&resolved) + } + None => None, + }; + let record = prepared.map(|plan| &plan.record).unwrap_or(record); + let effective_cfg = match prepared { + Some(plan) => plan.effective.clone(), + None => super::effective_config::resolve_effective_config(record, &personas, &global) + .require_resolved()?, + }; + let descriptor = match prepared { + Some(plan) => plan.descriptor.clone(), + None => super::resolve_effective_harness_descriptor(record, &personas, &global)?, + }; let effective_command = &descriptor.command; let agent_args = &descriptor.args; @@ -872,6 +902,14 @@ pub(crate) fn spawn_agent_child_with_broker( }, )?; } + // Applied last so inherited environment cannot weaken explicit model selection. + command.env_remove("BUZZ_ACP_REQUIRE_MODEL"); + if let Some(config) = super::runtime_configurations::selected(record)? { + command.env("BUZZ_ACP_REQUIRE_MODEL", "true"); + if let Some(workspace) = &config.workspace { + command.current_dir(workspace); + } + } let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", @@ -927,8 +965,51 @@ pub fn start_managed_agent_process( workspace_relay: &crate::relay::ScopedWorkspaceRelay, replay_floor_unix: Option, resume: Option<&super::remote_stop::ResumeTicket>, +) -> Result<(), String> { + start_managed_agent_process_prepared( + app, + record, + runtimes, + owner_hex, + workspace_relay, + replay_floor_unix, + resume, + None, + ) +} + +/// Ordinary pair registration using a previously resolved immutable launch. +#[allow(clippy::too_many_arguments)] +pub(crate) fn start_managed_agent_process_prepared( + app: &AppHandle, + record: &mut ManagedAgentRecord, + runtimes: &mut HashMap, + owner_hex: Option<&str>, + workspace_relay: &crate::relay::ScopedWorkspaceRelay, + replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, + prepared: Option<&super::runtime_configurations::PreparedLaunch>, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; + let resolved = if prepared.is_none() { + super::runtime_configurations::prepare_selected( + app, + record, + owner_hex, + workspace_relay.as_str(), + )? + } else { + None + }; + let prepared = prepared.or(resolved.as_ref()); + if let Some(plan) = prepared { + plan.check_scope(owner_hex, workspace_relay.as_str())?; + plan.revalidate( + record, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + )?; + } if let Some(runtime) = runtimes.get_mut(&key) { if runtime .child @@ -936,6 +1017,20 @@ pub fn start_managed_agent_process( .map_err(|error| format!("failed to inspect running process: {error}"))? .is_none() { + let requested = match prepared { + Some(plan) => { + plan.check_scope(owner_hex, workspace_relay.as_str())?; + plan.configuration() + } + None => super::runtime_configurations::selected_reference( + record, + owner_hex, + workspace_relay.as_str(), + )?, + }; + if runtime.spawn_config.runtime_configuration != requested { + return Err("A different configuration is running; Stop before Start".into()); + } return Ok(()); } @@ -950,7 +1045,7 @@ pub fn start_managed_agent_process( // replace. Selection enforces host-preserving authority provenance and // uses the ordinary process-tree termination contract. terminate_untracked_pair_runtime(app, &key)?; - let mut process = spawn_agent_child( + let mut process = spawn_agent_child_with_broker( app, record, workspace_relay.as_str(), @@ -958,14 +1053,17 @@ pub fn start_managed_agent_process( owner_hex, replay_floor_unix, resume, + None, + prepared, )?; let now = now_iso(); - let receipt = super::ManagedAgentRuntimeReceipt::new( + let mut receipt = super::ManagedAgentRuntimeReceipt::new( key.clone(), process.child.id(), current_instance_id(app), now.clone(), ); + receipt.runtime_configuration = process.spawn_config.runtime_configuration.clone(); if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { let _ = terminate_process(process.child.id()); let _ = process.child.wait(); diff --git a/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs index c27762cea80..59f270ff203 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs @@ -140,6 +140,7 @@ fn production_start_refuses_live_unversioned_receipt_before_spawn() { let key = crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, relay).unwrap(); let receipt = crate::managed_agents::ManagedAgentRuntimeReceipt { + runtime_configuration: None, authority_version: 0, key: key.clone(), pid: child.id(), diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 9b619dc19d0..3b2962b63a8 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -298,6 +298,7 @@ mod tests { let stored_key = ManagedAgentRuntimeKey::new(&pubkey, stored_relay).unwrap(); let requested_key = ManagedAgentRuntimeKey::new(&pubkey, requested_relay).unwrap(); let receipt = super::super::super::ManagedAgentRuntimeReceipt { + runtime_configuration: None, authority_version: 0, key: stored_key.clone(), pid, 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 841eb1ae647..fecd9586f56 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -159,6 +159,7 @@ pub(in crate::managed_agents) fn fixture( auth_tag: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "p".into(), name: "n".into(), diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 87318f8405c..7b9cc9b5eaf 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -863,6 +863,7 @@ fn receipt_fixture( key: crate::managed_agents::ManagedAgentRuntimeKey, ) -> crate::managed_agents::ManagedAgentRuntimeReceipt { crate::managed_agents::ManagedAgentRuntimeReceipt { + runtime_configuration: None, authority_version: crate::managed_agents::RUNTIME_AUTHORITY_RECEIPT_VERSION, key, pid: std::process::id(), diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 7bc12341b2f..2247209cf76 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -57,6 +57,7 @@ fn status_for_with( let effective = resolve_effective_agent_env(record, personas, metadata, global); let local_setup = matches!(agent_readiness(&effective), AgentReadiness::Ready); ManagedAgentRuntimeStatus { + running_configuration: runtime.and_then(|r| r.spawn_config.runtime_configuration.clone()), pubkey: key.pubkey.clone(), relay_url: key.relay_url.clone(), requested_relay_url, @@ -281,6 +282,30 @@ pub(crate) fn start_pair_locked( explicit_start: bool, broker: Option<&super::broker_launch::BrokerSession>, app: AppHandle, +) -> Result { + start_pair_prepared_locked( + pubkey, + relay_url, + lazy, + expected_updated_at, + explicit_start, + broker, + None, + app, + ) +} + +/// Caller owns the transition lock and admission; no selection is persisted here. +#[allow(clippy::too_many_arguments)] +pub(crate) fn start_pair_prepared_locked( + pubkey: String, + relay_url: String, + lazy: bool, + expected_updated_at: Option<&str>, + explicit_start: bool, + broker: Option<&super::broker_launch::BrokerSession>, + prepared: Option<&super::runtime_configurations::PreparedLaunch>, + app: AppHandle, ) -> Result { let state = app.state::(); if state.shutdown_started.load(Ordering::Acquire) { @@ -299,6 +324,21 @@ pub(crate) fn start_pair_locked( return Err("managed agent changed while runtime reconciliation was in flight".into()); } let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; + let owner = state.signing_keys()?.public_key().to_hex(); + let resolved = if prepared.is_none() { + super::runtime_configurations::prepare_selected(&app, record, Some(&owner), &relay_url)? + } else { + None + }; + let prepared = prepared.or(resolved.as_ref()); + if let Some(plan) = prepared { + plan.check_scope(Some(&owner), &relay_url)?; + plan.revalidate( + record, + &super::load_personas(&app)?, + &super::load_global_agent_config(&app)?, + )?; + } let mut runtimes = state .managed_agent_processes .lock() @@ -308,6 +348,23 @@ pub(crate) fn start_pair_locked( .is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none()) { let status = status_for(&app, record, &key, runtimes.get(&key), None); + let requested = match prepared { + Some(plan) => { + plan.check_scope( + Some(&state.signing_keys()?.public_key().to_hex()), + &relay_url, + )?; + plan.configuration() + } + None => super::runtime_configurations::selected_reference( + record, + Some(&state.signing_keys()?.public_key().to_hex()), + &relay_url, + )?, + }; + if status.running_configuration != requested { + return Err("A different configuration is running; Stop before Start".into()); + } return Ok(status); } runtimes.remove(&key); @@ -337,14 +394,16 @@ pub(crate) fn start_pair_locked( None, resume.as_ref(), broker, + prepared, )?; let now = crate::util::now_iso(); - let receipt = ManagedAgentRuntimeReceipt::new( + let mut receipt = ManagedAgentRuntimeReceipt::new( key.clone(), process.child.id(), current_instance_id(&app), now.clone(), ); + receipt.runtime_configuration = process.spawn_config.runtime_configuration.clone(); if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { let _ = terminate_process(process.child.id()); let _ = process.child.wait(); @@ -501,6 +560,7 @@ fn unkeyable_failed_status( let metadata = super::known_acp_runtime(&command); let effective = resolve_effective_agent_env(record, personas, metadata, global); ManagedAgentRuntimeStatus { + running_configuration: None, pubkey: record.pubkey.clone(), relay_url: requested.clone(), requested_relay_url: Some(requested), @@ -868,6 +928,7 @@ mod stop_scope_tests { let stored_key = super::ManagedAgentRuntimeKey::new(&pubkey, stored_relay).unwrap(); let requested_key = super::ManagedAgentRuntimeKey::new(&pubkey, requested_relay).unwrap(); let receipt = super::ManagedAgentRuntimeReceipt { + runtime_configuration: None, authority_version: 0, key: stored_key.clone(), pid, diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs new file mode 100644 index 00000000000..13b171bc19a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs @@ -0,0 +1,484 @@ +//! Named next-launch settings. Identity, persona and memory remain on the agent. +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{readiness::EffectiveHarnessDescriptor, ManagedAgentRecord}; + +/// One destination-local configuration; references contain names, never credentials. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfiguration { + pub id: String, + pub revision: String, + pub name: String, + pub host: String, + pub runtime: String, + pub model: String, + pub provider: Option, + pub workspace: Option, + /// Target environment key -> key in the existing host-local configuration. + #[serde(default)] + pub credential_refs: BTreeMap, +} + +pub use buzz_core_pkg::desktop_lifecycle::RuntimeConfigurationRef; +mod store; +pub(crate) use store::selected_reference; +pub use store::RuntimeConfigurationStore; + +impl RuntimeConfiguration { + pub(crate) fn reference(&self) -> RuntimeConfigurationRef { + RuntimeConfigurationRef { + id: self.id.clone(), + revision: self.revision.clone(), + } + } +} + +/// Native-only immutable inputs. Never serialize resolved environment or identity keys. +pub(crate) struct PreparedLaunch { + pub(super) record: ManagedAgentRecord, + pub(super) descriptor: EffectiveHarnessDescriptor, + pub(super) effective: super::effective_config::EffectiveAgentConfig, + host: String, + scope: (String, String), +} + +impl PreparedLaunch { + pub(crate) fn check_scope(&self, owner: Option<&str>, community: &str) -> Result<(), String> { + if Some(self.scope.0.as_str()) != owner || self.scope.1 != community { + return Err("Prepared runtime launch belongs to another owner or community".into()); + } + Ok(()) + } + + pub(crate) fn record(&self) -> &ManagedAgentRecord { + &self.record + } + pub(crate) fn configuration(&self) -> Option { + selected(&self.record) + .ok() + .flatten() + .map(RuntimeConfiguration::reference) + } + + /// Fail closed if the record or any resolved launch prerequisite changed. + pub(crate) fn revalidate( + &self, + record: &ManagedAgentRecord, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, + ) -> Result<(), String> { + // Ignore lifecycle bookkeeping and other scopes, not launch inputs. A confirmed + // Stop changes timestamps but must not invalidate an otherwise exact plan. + let mut comparable = record.clone(); + comparable.runtime_configurations = self.record.runtime_configurations.clone(); + comparable.updated_at = self.record.updated_at.clone(); + comparable.runtime_pid = self.record.runtime_pid; + comparable.last_started_at = self.record.last_started_at.clone(); + comparable.last_stopped_at = self.record.last_stopped_at.clone(); + comparable.last_exit_code = self.record.last_exit_code; + comparable.last_error = self.record.last_error.clone(); + comparable.last_error_code = self.record.last_error_code; + if comparable != self.record { + return Err("Agent changed during runtime preflight; retry Start".into()); + } + let current = prepare( + record, + self.configuration().as_ref(), + personas, + global, + &self.host, + &self.scope.0, + &self.scope.1, + )?; + if selected(¤t.record)? != selected(&self.record)? + || current.descriptor != self.descriptor + || current.effective != self.effective + { + return Err("Launch settings changed during runtime preflight; retry Start".into()); + } + Ok(()) + } +} + +/// Resolve an explicit reference without mutating the agent's durable selection. +/// `None` explicitly means legacy Default, never "read selection later". +pub(crate) fn prepare( + record: &ManagedAgentRecord, + reference: Option<&RuntimeConfigurationRef>, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, + host: &str, + owner: &str, + community: &str, +) -> Result { + verify_owner(record, owner)?; + let mut projected = record.clone(); + let configurations = record.runtime_configurations.get(owner, community); + configurations.validate()?; + projected.runtime_configurations.launch = reference.map(|reference| { + configurations.entries.iter() + .find(|c| c.id == reference.id && c.revision == reference.revision && c.host == host) + .cloned().ok_or_else(|| "Runtime configuration is unavailable in this Desktop scope or its revision changed".to_string()) + }).transpose()?; + let descriptor = super::resolve_effective_harness_descriptor(&projected, personas, global)?; + preflight(&projected, &descriptor, host)?; + let effective = super::effective_config::resolve_effective_config(&projected, personas, global) + .require_resolved()?; + Ok(PreparedLaunch { + record: projected, + descriptor, + effective, + host: host.into(), + scope: (owner.into(), community.into()), + }) +} + +/// Absent selection is the legacy Default configuration, with unchanged inheritance. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfigurations { + pub selected: Option, + pub entries: Vec, +} + +impl RuntimeConfigurations { + pub(crate) fn selected(&self) -> Result, String> { + self.selected + .as_ref() + .map(|id| { + self.entries + .iter() + .find(|entry| &entry.id == id) + .ok_or_else(|| "Selected runtime configuration is missing".to_string()) + }) + .transpose() + } + + pub(crate) fn validate(&self) -> Result<(), String> { + if self.entries.len() > 32 { + return Err("At most 32 runtime configurations are supported".into()); + } + self.selected()?; + let mut ids = std::collections::BTreeSet::new(); + for entry in &self.entries { + if !ids.insert(&entry.id) + || uuid::Uuid::parse_str(&entry.id).is_err() + || uuid::Uuid::parse_str(&entry.revision).is_err() + || entry.host.trim().is_empty() + || entry.host.len() > 128 + || entry.name.trim().is_empty() + || entry.name.len() > 120 + || entry.model.trim().is_empty() + || entry.model.len() > 512 + || entry.runtime.trim().is_empty() + || entry.runtime.len() > 128 + || [&entry.name, &entry.runtime, &entry.model] + .iter() + .any(|text| text.chars().any(char::is_control)) + || entry.provider.as_ref().is_some_and(|text| { + text.trim().is_empty() || text.len() > 128 || text.chars().any(char::is_control) + }) + || entry.credential_refs.len() > 32 + || entry + .workspace + .as_ref() + .is_some_and(|p| p.len() > 4096 || !std::path::Path::new(p).is_absolute()) + || entry + .credential_refs + .iter() + .any(|(key, source)| !reference_name(key) || !reference_name(source)) + { + return Err("Invalid destination-local runtime configuration".into()); + } + } + Ok(()) + } +} + +fn reference_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + && !super::env_vars::is_reserved_env_key(value) +} + +pub(crate) fn selected( + record: &ManagedAgentRecord, +) -> Result, String> { + Ok(record.runtime_configurations.launch.as_ref()) +} + +/// Apply pins after inherited env: saved environment cannot silently override the pick. +pub(crate) fn apply_descriptor( + record: &ManagedAgentRecord, + descriptor: &mut EffectiveHarnessDescriptor, +) -> Result<(), String> { + let Some(config) = selected(record)? else { + return Ok(()); + }; + let runtime = super::known_acp_runtime(&descriptor.command) + .ok_or("Named configuration requires a catalogued runtime")?; + if runtime.provider_locked && config.provider.is_some() { + return Err("Selected provider is not supported by this runtime".into()); + } + let references = config + .credential_refs + .iter() + .map(|(target, source)| { + if !reference_name(target) || !reference_name(source) { + return Err("Invalid credential reference".to_string()); + } + descriptor + .env + .get(source) + .filter(|v| !v.trim().is_empty()) + .cloned() + .map(|value| (target.clone(), value)) + .ok_or_else(|| "A local credential reference is unavailable".to_string()) + }) + .collect::, _>>()?; + descriptor.env.extend(references); + for (key, value) in super::runtime::runtime_metadata_env_vars( + runtime.model_env_var, + runtime.provider_env_var, + runtime.provider_locked, + Some(&config.model), + config.provider.as_deref(), + ) { + descriptor.env.insert(key.into(), value.into()); + } + Ok(()) +} + +/// Recheck before launch, never substitute a setup listener for the selected runtime. +pub(crate) fn preflight( + record: &ManagedAgentRecord, + descriptor: &EffectiveHarnessDescriptor, + host: &str, +) -> Result<(), String> { + let config = selected(record)?; + if config.is_some_and(|config| config.host != host) { + return Err("Runtime configuration belongs to another Desktop".into()); + } + if let Some(error) = super::storage::spawn_key_refusal(record) { + return Err(error); + } + let keys = nostr::Keys::parse(record.private_key_nsec.trim()) + .map_err(|_| "Local identity access is unavailable")?; + if keys.public_key().to_hex() != record.pubkey { + return Err("Local identity does not match this agent".into()); + } + if super::resolve_command(&record.acp_command).is_none() { + return Err("ACP runtime is unavailable".into()); + } + if super::resolve_command(&descriptor.command).is_none() { + return Err("Selected runtime is unavailable on this Desktop".into()); + } + if config + .and_then(|c| c.workspace.as_ref()) + .is_some_and(|path| !std::path::Path::new(path).is_dir()) + { + return Err("Selected workspace is unavailable on this Desktop".into()); + } + let effective = super::readiness::EffectiveAgentEnv { + env: descriptor.env.clone(), + effective_command: descriptor.command.clone(), + config_file_path: super::known_acp_runtime(&descriptor.command) + .and_then(|r| r.config_file_path), + }; + if !matches!( + super::agent_readiness(&effective), + super::AgentReadiness::Ready + ) { + return Err("Selected configuration is not ready on this Desktop; check runtime and local credentials".into()); + } + Ok(()) +} + +fn verify_owner(record: &ManagedAgentRecord, owner: &str) -> Result<(), String> { + let verified = record.auth_tag.as_deref().and_then(|tag| { + let agent = nostr::PublicKey::from_hex(&record.pubkey).ok()?; + buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &agent).ok() + }); + if record.backend != super::BackendKind::Local + || !verified.is_some_and(|key| key.to_hex() == owner) + { + return Err("Agent ownership is unavailable in this scope".into()); + } + Ok(()) +} + +/// Resolve on this owner/community's Desktop using the canonical local host identity. +pub(crate) fn prepare_for_app( + app: &tauri::AppHandle, + record: &ManagedAgentRecord, + reference: Option<&RuntimeConfigurationRef>, + owner: &str, + community: &str, +) -> Result { + let host = local_host(app, owner, community)?; + prepare( + record, + reference, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + &host, + owner, + community, + ) +} + +fn local_host( + app: &tauri::AppHandle, + owner: &str, + community: &str, +) -> Result { + use tauri::Manager; + let state = app.state::(); + let keys = state.signing_keys()?; + if keys.public_key().to_hex() != owner { + return Err("Desktop owner changed".into()); + } + let scope = super::retention::RetentionScope { + db_path: super::retention::scoped_retention_db_path( + &super::managed_agents_base_dir(app)?, + community, + owner, + ), + relay_url: community.into(), + owner_keys: keys, + }; + crate::commands::desktop_stop::local_id( + &mut super::retention::open_retention_db(&scope.db_path)?, + &scope, + ) +} + +/// Owner-private launch choices; deliberately excludes workspace, key references and env. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeConfigurationSummary { + pub configuration: Option, + pub name: String, + pub host: String, + pub runtime: String, + pub model: Option, + pub provider: Option, + pub eligible: bool, +} + +/// Shared readiness projection. A missing/unknown prerequisite is never eligible. +pub(crate) fn catalog( + record: &ManagedAgentRecord, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, + host: &str, + owner: &str, + community: &str, +) -> Vec { + let configurations = record.runtime_configurations.get(owner, community); + std::iter::once(None) + .chain(configurations.entries.iter().map(Some)) + .map(|config| { + let reference = config.map(RuntimeConfiguration::reference); + let mut projected = record.clone(); + projected.runtime_configurations.launch = config.cloned(); + let effective = + super::effective_config::resolve_effective_config(&projected, personas, global) + .require_resolved() + .ok(); + RuntimeConfigurationSummary { + configuration: reference.clone(), + name: config + .map(|c| c.name.clone()) + .unwrap_or_else(|| "Default".into()), + host: config + .map(|c| c.host.clone()) + .unwrap_or_else(|| host.into()), + runtime: config + .map(|c| c.runtime.clone()) + .unwrap_or_else(|| super::record_agent_command(&projected, personas)), + model: effective.as_ref().and_then(|e| e.model.value.clone()), + provider: effective.and_then(|e| e.provider.value), + eligible: prepare( + record, + reference.as_ref(), + personas, + global, + host, + owner, + community, + ) + .is_ok(), + } + }) + .collect() +} + +/// Validate or resolve before any existing pair is reaped. The shared spawn checks again. +pub(crate) fn prepare_selected( + app: &tauri::AppHandle, + record: &ManagedAgentRecord, + owner: Option<&str>, + community: &str, +) -> Result, String> { + selected_reference(record, owner, community)? + .map(|reference| { + prepare_for_app( + app, + record, + Some(&reference), + owner.ok_or("Desktop owner unavailable")?, + community, + ) + }) + .transpose() +} + +/// Scoped safe catalog for lifecycle consumers; never exposes the global configuration store. +pub(crate) fn catalog_for_app( + app: &tauri::AppHandle, + record: &ManagedAgentRecord, + owner: &str, + community: &str, +) -> Result, String> { + verify_owner(record, owner)?; + let host = local_host(app, owner, community)?; + Ok(catalog( + record, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + &host, + owner, + community, + )) +} + +/// Ordinary async mesh readiness, outside the transition lock. Caller revalidates +/// owner/community after this await and the immutable plan under its admission lock. +pub(crate) async fn preflight_prepared( + app: &tauri::AppHandle, + plan: &PreparedLaunch, + owner: &str, + community: &str, +) -> Result<(), String> { + plan.check_scope(Some(owner), community)?; + #[cfg(feature = "mesh-llm")] + crate::commands::ensure_relay_mesh_for_record( + app, + plan.effective.relay_mesh_model_id().as_deref(), + false, + ) + .await?; + #[cfg(not(feature = "mesh-llm"))] + let _ = app; + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/store.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/store.rs new file mode 100644 index 00000000000..3e41f73f32b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/store.rs @@ -0,0 +1,75 @@ +//! Durable scope is owner + community, independent of the global agent identity. +use super::*; + +/// Only the scoped sets are persisted. The resolved launch is an immutable native projection. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfigurationStore { + scopes: BTreeMap>, + #[serde(skip)] + pub(super) launch: Option, +} + +impl RuntimeConfigurationStore { + pub(crate) fn get(&self, owner: &str, community: &str) -> RuntimeConfigurations { + self.scopes + .get(owner) + .and_then(|communities| communities.get(community)) + .cloned() + .unwrap_or_default() + } + + /// Replace one authorized scope atomically; callers persist the whole agent once. + pub(crate) fn replace( + &mut self, + owner: &str, + community: &str, + host: &str, + mut next: RuntimeConfigurations, + ) -> Result<(), String> { + next.validate()?; + let previous = self.get(owner, community); + // A local editor may preserve, but neither invent, modify nor delete another host's entries. + for entry in previous.entries.iter().filter(|entry| entry.host != host) { + if !next.entries.contains(entry) { + return Err("Another Desktop's configuration cannot be changed here".into()); + } + } + for entry in &mut next.entries { + let old = previous.entries.iter().find(|old| old.id == entry.id); + if entry.host != host && old != Some(entry) { + return Err("Another Desktop's configuration cannot be changed here".into()); + } + if old.is_some_and(|old| old.host != entry.host) { + return Err("A configuration's Desktop cannot be changed".into()); + } + if old != Some(entry) { + entry.revision = uuid::Uuid::new_v4().to_string(); + } + } + if next.selected()?.is_some_and(|entry| entry.host != host) { + return Err("Select a configuration on this Desktop".into()); + } + self.scopes + .entry(owner.into()) + .or_default() + .insert(community.into(), next); + Ok(()) + } +} + +/// Capture selection at the actual launch pair, never the record's creation community. +pub(crate) fn selected_reference( + record: &ManagedAgentRecord, + owner: Option<&str>, + community: &str, +) -> Result, String> { + let Some(owner) = owner else { + return Ok(None); + }; + record + .runtime_configurations + .get(owner, community) + .selected() + .map(|entry| entry.map(RuntimeConfiguration::reference)) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs new file mode 100644 index 00000000000..051f04a2cc4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs @@ -0,0 +1,380 @@ +use super::*; +use crate::managed_agents as agents; + +fn record() -> ManagedAgentRecord { + let mut record = + agents::runtime::test_fixtures::fixture(agents::RespondTo::OwnerOnly, vec![], None); + let keys = nostr::Keys::generate(); + record.pubkey = keys.public_key().to_hex(); + record.private_key_nsec = keys.secret_key().to_secret_hex(); + record +} + +fn attest(record: &mut ManagedAgentRecord, owner: &nostr::Keys) -> String { + record.auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag( + owner, + &nostr::PublicKey::from_hex(&record.pubkey).unwrap(), + "", + ) + .unwrap(), + ); + owner.public_key().to_hex() +} + +fn config(host: &str) -> RuntimeConfiguration { + RuntimeConfiguration { + id: uuid::Uuid::new_v4().to_string(), + revision: uuid::Uuid::new_v4().to_string(), + name: "Focused".into(), + host: host.into(), + runtime: "buzz-agent".into(), + model: "fixture-model".into(), + provider: Some("openai".into()), + workspace: None, + credential_refs: BTreeMap::new(), + } +} + +fn save( + record: &mut ManagedAgentRecord, + owner: &str, + community: &str, + entry: RuntimeConfiguration, +) -> RuntimeConfiguration { + record + .runtime_configurations + .replace( + owner, + community, + &entry.host, + RuntimeConfigurations { + selected: Some(entry.id.clone()), + entries: vec![entry], + }, + ) + .unwrap(); + record + .runtime_configurations + .get(owner, community) + .entries + .remove(0) +} + +#[test] +fn migration_keeps_default_and_launch_projection_is_not_persisted() { + let original = record(); + let mut json = serde_json::to_value(&original).unwrap(); + json.as_object_mut() + .unwrap() + .remove("runtime_configurations"); + let mut migrated: ManagedAgentRecord = serde_json::from_value(json).unwrap(); + assert!(selected_reference(&migrated, Some("owner"), "community") + .unwrap() + .is_none()); + migrated.runtime_configurations.launch = Some(config("host")); + let resolved = + agents::effective_config::resolve_effective_config(&migrated, &[], &Default::default()) + .require_resolved() + .unwrap(); + assert_eq!(resolved.model.value.as_deref(), Some("fixture-model")); + assert_eq!( + resolved.model.source, + agents::effective_config::ConfigSource::RuntimeConfiguration + ); + let restored: ManagedAgentRecord = + serde_json::from_value(serde_json::to_value(&migrated).unwrap()).unwrap(); + assert!(selected(&restored).unwrap().is_none()); + assert_eq!(restored.pubkey, original.pubkey); + assert_eq!(restored.private_key_nsec, original.private_key_nsec); +} + +#[test] +fn agent_in_two_communities_preserves_private_sets_and_rejects_foreign_references() { + let mut record = record(); + let owner = attest(&mut record, &nostr::Keys::generate()); + let first = save(&mut record, &owner, "one", config("host-one")); + let second = save(&mut record, &owner, "two", config("host-two")); + let saved_two = record.runtime_configurations.get(&owner, "two"); + let mut changed = first.clone(); + changed.name = "Edited in one".into(); + let changed = save(&mut record, &owner, "one", changed); + assert_ne!(changed.revision, first.revision); + assert_eq!(record.runtime_configurations.get(&owner, "two"), saved_two); + assert_eq!( + selected_reference(&record, Some(&owner), "two").unwrap(), + Some(second.reference()) + ); + let view = record.runtime_configurations.get(&owner, "one"); + assert!(!serde_json::to_string(&view).unwrap().contains(&second.id)); + assert!(!serde_json::to_string(&catalog( + &record, + &[], + &Default::default(), + "host-one", + &owner, + "one" + )) + .unwrap() + .contains(&second.id)); + assert!(record + .runtime_configurations + .get("other-owner", "one") + .entries + .is_empty()); + for (reference, host, requested_owner, community) in [ + (&second, "host-two", owner.as_str(), "one"), + (&first, "host-two", owner.as_str(), "one"), + (&changed, "host-one", "other-owner", "one"), + ] { + assert!(prepare( + &record, + Some(&reference.reference()), + &[], + &Default::default(), + host, + requested_owner, + community + ) + .is_err()); + } + assert!(catalog( + &record, + &[], + &Default::default(), + "host-one", + "other-owner", + "one" + ) + .iter() + .all(|c| !c.eligible)); + let mut bad_selection = view.clone(); + bad_selection.selected = Some(second.id); + assert!(record + .runtime_configurations + .replace(&owner, "one", "host-one", bad_selection) + .is_err()); + assert_eq!(record.runtime_configurations.get(&owner, "one"), view); +} + +#[test] +fn other_host_entries_are_preserved_not_a_global_validation_failure() { + let mut record = record(); + let first = save(&mut record, "owner", "one", config("host-one")); + let mut set = record.runtime_configurations.get("owner", "one"); + let second = config("host-two"); + set.entries.push(second.clone()); + set.selected = None; + record + .runtime_configurations + .replace("owner", "one", "host-two", set) + .unwrap(); + let mut set = record.runtime_configurations.get("owner", "one"); + set.selected = Some(first.id.clone()); + record + .runtime_configurations + .replace("owner", "one", "host-one", set.clone()) + .unwrap(); + set.selected = Some(second.id.clone()); + assert!(record + .runtime_configurations + .replace("owner", "one", "host-one", set) + .is_err()); + let mut set = record.runtime_configurations.get("owner", "one"); + set.entries.retain(|c| c.id == first.id); + assert!(record + .runtime_configurations + .replace("owner", "one", "host-one", set) + .is_err()); + let mut set = record.runtime_configurations.get("owner", "one"); + set.entries[1].model = "tamper".into(); + assert!(record + .runtime_configurations + .replace("owner", "one", "host-one", set) + .is_err()); +} + +#[test] +fn malformed_or_stale_reference_is_rejected_before_launch_resolution() { + let mut record = record(); + let owner = attest(&mut record, &nostr::Keys::generate()); + let mut named = save(&mut record, &owner, "one", config("host")); + named.revision = uuid::Uuid::new_v4().to_string(); + assert!(prepare( + &record, + Some(&named.reference()), + &[], + &Default::default(), + "host", + &owner, + "one" + ) + .is_err()); + named + .credential_refs + .insert("BUZZ_PRIVATE_KEY".into(), "SECRET".into()); + assert!(RuntimeConfigurations { + selected: None, + entries: vec![named] + } + .validate() + .is_err()); +} + +#[cfg(unix)] +#[test] +fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { + use std::os::unix::fs::PermissionsExt; + use tauri::Manager; + let _guard = agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + struct Restore(Vec<(&'static str, Option)>); + impl Drop for Restore { + fn drop(&mut self) { + for (key, value) in self.0.drain(..) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + agents::clear_resolve_cache(); + } + } + let _restore = Restore( + ["HOME", "XDG_DATA_HOME", "PATH"] + .map(|key| (key, std::env::var_os(key))) + .to_vec(), + ); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + std::env::set_var("PATH", format!("{}:/usr/bin:/bin", temp.path().display())); + agents::clear_resolve_cache(); + let capture = temp.path().join("capture"); + // A fixture child at the real spawn boundary, not an ACP/session or live-model claim. + for (name, script) in [("buzz-agent", "#!/bin/sh\nexit 0\n".to_string()), ("buzz-acp", format!("#!/bin/sh\nprintf '%s\\n' \"$BUZZ_ACP_MODEL\" \"$BUZZ_AGENT_MODEL\" \"$BUZZ_AGENT_PROVIDER\" \"$BUZZ_ACP_REQUIRE_MODEL\" \"$PWD\" > '{}'\n", capture.display()))] { + let path = temp.path().join(name); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let owner = app + .state::() + .signing_keys() + .unwrap() + .public_key() + .to_hex(); + let community = "wss://runtime-fixture.example"; + let mut record = record(); + attest( + &mut record, + &app.state::() + .signing_keys() + .unwrap(), + ); + record.acp_command = temp.path().join("buzz-acp").display().to_string(); + record.env_vars.insert( + "OPENAI_COMPAT_API_KEY".into(), + "fixture-not-a-secret".into(), + ); + // Resolve the app-scoped host independently of a selected named configuration. + let base = prepare_for_app(&app.handle().clone(), &record, None, &owner, community); + // Default may lack a provider: obtain the same host identity used by prepare_for_app. + drop(base); + let scope = agents::retention::RetentionScope { + db_path: agents::retention::scoped_retention_db_path( + &agents::managed_agents_base_dir(app.handle()).unwrap(), + community, + &owner, + ), + relay_url: community.into(), + owner_keys: app + .state::() + .signing_keys() + .unwrap(), + }; + let host = crate::commands::desktop_stop::local_id( + &mut agents::retention::open_retention_db(&scope.db_path).unwrap(), + &scope, + ) + .unwrap(); + let mut named = config(&host); + named.workspace = Some(temp.path().display().to_string()); + let named = save(&mut record, &owner, community, named); + let plan = prepare_for_app( + app.handle(), + &record, + Some(&named.reference()), + &owner, + community, + ) + .unwrap(); + assert_eq!( + selected_reference(&record, Some(&owner), community).unwrap(), + Some(named.reference()) + ); + assert_eq!(plan.configuration(), Some(named.reference())); + assert!(plan + .check_scope(Some(&owner), "wss://other.example") + .is_err()); + let mut edited = record.clone(); + let mut changed = named.clone(); + changed.model = "other-model".into(); + save(&mut edited, &owner, community, changed); + assert!(plan.revalidate(&edited, &[], &Default::default()).is_err()); + edited = record.clone(); + edited.private_key_nsec.clear(); + assert!(plan.revalidate(&edited, &[], &Default::default()).is_err()); + edited = record.clone(); + edited.parallelism += 1; + assert!(plan.revalidate(&edited, &[], &Default::default()).is_err()); + edited = record.clone(); + edited.updated_at = "stopped".into(); + edited.last_stopped_at = Some("stopped".into()); + assert!(plan.revalidate(&edited, &[], &Default::default()).is_ok()); + let safe = serde_json::to_string(&catalog( + &record, + &[], + &Default::default(), + &host, + &owner, + community, + )) + .unwrap(); + assert!(!safe.contains("credentialRefs")); + assert!(!safe.contains("workspace")); + assert!(!safe.contains("fixture-not-a-secret")); + let relay = crate::relay::bind_expected_relay_scope(None, community.into()).unwrap(); + let mut runtimes = std::collections::HashMap::new(); + agents::start_managed_agent_process_prepared( + app.handle(), + &mut record, + &mut runtimes, + Some(&owner), + &relay, + None, + None, + Some(&plan), + ) + .unwrap(); + let key = agents::ManagedAgentRuntimeKey::new(&record.pubkey, community).unwrap(); + let mut running = runtimes.remove(&key).unwrap(); + assert!(running.child.wait().unwrap().success()); + assert_eq!( + running.spawn_config.runtime_configuration, + Some(named.reference()) + ); + assert_eq!( + std::fs::read_to_string(capture).unwrap(), + format!( + "fixture-model\nfixture-model\nopenai\ntrue\n{}\n", + temp.path().display() + ) + ); + assert!(agents::read_all_agent_runtime_receipts(app.handle()) + .iter() + .any(|(_, receipt)| receipt.runtime_configuration == Some(named.reference()))); + agents::remove_agent_runtime_receipt(app.handle(), &key); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs index 2351e60c6f0..1660a88b085 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_types.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -139,6 +139,7 @@ impl ManagedAgentPairRuntime { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ManagedAgentRuntimeStatus { + pub running_configuration: Option, pub pubkey: String, pub relay_url: String, /// Exact descriptor URL echoed only by reconcile result rows so callers can @@ -171,6 +172,8 @@ pub struct ManagedAgentCommunityTarget { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ManagedAgentRuntimeReceipt { + #[serde(default)] + pub runtime_configuration: Option, /// Version 0 is an unversioned legacy receipt. Its lossy host/path rendering /// cannot prove pair authority; it is usable only for instance-wide cleanup. #[serde(default)] @@ -189,6 +192,7 @@ impl ManagedAgentRuntimeReceipt { started_at: String, ) -> Self { Self { + runtime_configuration: None, authority_version: RUNTIME_AUTHORITY_RECEIPT_VERSION, key, pid, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 810ad439f29..db8681331d9 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -101,6 +101,7 @@ pub(crate) struct SpawnConfigInputs<'a> { /// [`ManagedAgentProcess`]: super::ManagedAgentProcess #[derive(Clone, Serialize)] pub(crate) struct SpawnConfigSnapshot { + pub runtime_configuration: Option, /// The ACP harness binary the desktop launches (`buzz-acp`). pub acp_command: String, /// The effective agent command the harness drives. @@ -195,6 +196,10 @@ impl SpawnConfigSnapshot { let (respond_to, respond_to_allowlist) = super::projected_access_with_policy(record, enforced_owner_only); Self { + runtime_configuration: super::runtime_configurations::selected(record) + .ok() + .flatten() + .map(super::runtime_configurations::RuntimeConfiguration::reference), acp_command: record.acp_command.clone(), command: descriptor.command.clone(), args: descriptor.args.clone(), diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs index 43ce7718595..5513f17e123 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -8,6 +8,7 @@ const RELAY_WITH_TOKEN: &str = "wss://relay.example/ws?token=SENTINEL"; /// coverage guard below sees the full serialized key set. fn base() -> SpawnConfigSnapshot { SpawnConfigSnapshot { + runtime_configuration: None, acp_command: "buzz-acp".into(), command: "goose".into(), args: vec!["--mode".into(), "acp".into()], diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 388256e01c6..f3a4012c5ac 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -43,6 +43,7 @@ fn snap(record: &ManagedAgentRecord) -> serde_json::Value { fn record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "p".repeat(64), name: "agent".into(), diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index fdeb54c4f27..9b5c4e71cd9 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -254,6 +254,7 @@ mod tests { /// Build a minimal `ManagedAgentRecord` for use as a team member. fn agent_record(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: format!("{name}-pubkey"), name: name.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index fc6f0f1a97b..430687f9340 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -167,6 +167,7 @@ fn validate_team_deletion_rejects_built_ins() { fn managed_agent(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: name.to_string(), name: name.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2620f0337fc..2e5a016cbf2 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -110,6 +110,7 @@ impl AgentDefinition { /// event coordinate (`d_tag = slug`) across the fold. pub fn into_agent_record(self) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: String::new(), name: self.display_name.clone(), persona_id: None, @@ -229,6 +230,9 @@ pub struct RelayAgentInfo { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ManagedAgentRecord { + /// Named launch choices; absence preserves the existing Default behavior. + #[serde(default)] + pub runtime_configurations: super::runtime_configurations::RuntimeConfigurationStore, pub pubkey: String, pub name: String, #[serde(default)] diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index a6b0855394d..7c76275dbf4 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -414,3 +414,27 @@ matches the code is worse than no rule; a new pattern that isn't written down here will be broken by the next agent that never learns it existed. Reviewers: treat a config-behavior diff without a matching AGENTS.md diff (or an explicit "no rules changed" note) as incomplete. + +## Named runtime configurations + +The Agents-page editor manages one exact agent in an owner + community scope. +Durable configuration sets live on the global agent record keyed by that scope; +IPC reads/replaces only the authorized set and preserves every other scope. +Host is a configuration field, not the scope of the whole global record. +A local editor may preserve another host's entries but cannot edit/delete/select +them. Missing legacy sets mean Default, with the existing inheritance behavior. + +Selection/editing is next-launch state, never a model switch or a running receipt. +Start sends the exact `{id, revision}` (null explicitly means Default). Native +preparation projects immutable launch inputs without persisting over identity or +persona fields, checks local identity access and destination prerequisites, then +revalidates after async readiness and at shared spawn. A stale revision or missing +prerequisite fails rather than falling back. Auto-restart is suppressed in the +active scoped summary while named configurations or a named running launch exist; +saving one scope does not change the global auto-restart preference. + +The editor's running revision comes from the live pair's spawn snapshot, not its +selection. Unavailable choices never remove independent Stop controls. Model IDs +are explicit authored values; credentials are provisioned locally, never entered +or copied by this editor. See `docs/named-runtime-configurations.md` for the native +contract and the distinction between fixture checks and real execution evidence. diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 1212a8b8826..5c879e70913 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,3 +1,4 @@ +import { RuntimeConfigurations } from "./RuntimeConfigurations"; import { KnownDesktops } from "./KnownDesktops"; import * as React from "react"; import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; @@ -219,6 +220,7 @@ export function AgentsView() { title="Agents" /> +
    ({ id, revision }); +const entry = (id) => ({ + ...reference(id), + name: id, + host: "host", + runtime: "buzz-agent", + model: `model-${id}`, + provider: "openai", + workspace: null, + credentialRefs: {}, +}); +const initial = () => ({ + configurations: { + selected: "A", + entries: [entry("A"), entry("B"), entry("unavailable")], + }, + catalog: ["A", "B", "unavailable"].map((id) => ({ + configuration: reference(id), + name: id, + runtime: "buzz-agent", + model: `model-${id}`, + eligible: id !== "unavailable", + })), + host: "host", + updatedAt: "before", + running: reference("A"), +}); + +test("mounted local configs save next selection without restarting, then start the exact saved revision", async () => { + const dom = new JSDOM("
    ", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")); + let current = initial(); + const calls = []; + let refuse = false; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + calls.push({ command, args }); + assert.equal(args.owner, "owner"); + assert.equal(args.community, "wss://one.test"); + assert.equal(args.agent, "agent"); + if (command === "get_runtime_configurations") + return structuredClone(current); + if (command === "save_runtime_configurations") { + assert.equal(args.expectedUpdatedAt, current.updatedAt); + current = { + ...current, + configurations: args.configurations, + updatedAt: "saved", + }; + return structuredClone(current); + } + if (command === "start_runtime_configuration") { + if (refuse) throw Error("missing prerequisite"); + current = { ...current, running: args.configuration }; + return {}; + } + throw Error(command); + }, + }; + const click = (text) => + React.act(async () => { + const button = [...document.querySelectorAll("button")].find( + (b) => b.textContent === text, + ); + assert.ok(button, text); + assert.equal(button.disabled, false, text); + button.click(); + }); + try { + await React.act(async () => + root.render( + React.createElement(RuntimeConfigurationEditor, { + scope: { owner: "owner", community: "wss://one.test" }, + agent: "agent", + runtimes: [], + }), + ), + ); + const picker = document.querySelector( + 'select[aria-label="Next runtime configuration"]', + ); + assert.deepEqual( + [...picker.options].map((o) => o.value), + ["A", "B"], + ); + await React.act(async () => { + picker.value = "B"; + picker.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + assert.equal( + calls.filter((c) => c.command === "start_runtime_configuration").length, + 0, + ); + assert.match(document.body.textContent, /Next Start: B/); + assert.match(document.body.textContent, /Running revision: A \/ old/); + refuse = true; + await click("Start configuration"); + assert.match( + document.body.textContent, + /no new running configuration was confirmed/, + ); + assert.match(document.body.textContent, /Running revision: A \/ old/); + assert.deepEqual(calls.at(-1).args.configuration, reference("B")); + refuse = false; + await click("Start configuration"); + assert.match(document.body.textContent, /Running revision: B \/ old/); + await click("Edit B"); + await click("Cancel configuration edit"); + assert.equal( + calls.filter((c) => c.command === "save_runtime_configurations").length, + 1, + ); + } finally { + await React.act(async () => root.unmount()); + dom.window.close(); + } +}); + +test("late configuration read after unmount does not display another scope's settings", async () => { + const dom = new JSDOM("
    ", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")); + let finish; + window.__TAURI_INTERNALS__ = { + invoke: () => + new Promise((resolve) => { + finish = resolve; + }), + }; + await React.act(async () => + root.render( + React.createElement(RuntimeConfigurationEditor, { + scope: { owner: "owner", community: "wss://one.test" }, + agent: "agent", + runtimes: [], + }), + ), + ); + await React.act(async () => root.unmount()); + await React.act(async () => finish(initial())); + assert.equal(document.body.textContent, ""); + dom.window.close(); +}); diff --git a/desktop/src/features/agents/ui/RuntimeConfigurations.tsx b/desktop/src/features/agents/ui/RuntimeConfigurations.tsx new file mode 100644 index 00000000000..a61539c3b60 --- /dev/null +++ b/desktop/src/features/agents/ui/RuntimeConfigurations.tsx @@ -0,0 +1,388 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useAcpRuntimesQuery, useManagedAgentsQuery } from "../hooks"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import type { DesktopScope } from "../desktopList"; +import { Button } from "@/shared/ui/button"; + +export type ConfigurationRef = { id: string; revision: string }; +type Configuration = ConfigurationRef & { + name: string; + host: string; + runtime: string; + model: string; + provider: string | null; + workspace: string | null; + credentialRefs: Record; +}; +type ConfigurationSet = { selected: string | null; entries: Configuration[] }; +export type ConfigurationView = { + configurations: ConfigurationSet; + updatedAt: string; + host: string; + running: ConfigurationRef | null; + catalog: { + configuration: ConfigurationRef | null; + name: string; + runtime: string; + model: string | null; + eligible: boolean; + }[]; +}; + +/** Management is local and owner-scoped; lifecycle Stop remains an independent control. */ +export function RuntimeConfigurations() { + const owner = useIdentityQuery().data?.pubkey; + const { activeCommunity } = useCommunities(); + const community = activeCommunity?.relayUrl + .trim() + .replace(/^http/, "ws") + .replace(/\/+$/, ""); + const agents = useManagedAgentsQuery(); + const runtimes = useAcpRuntimesQuery(); + const [agent, setAgent] = useState(""); + if (!owner || !community) return null; + const local = agents.data?.filter((a) => a.backend.type === "local") ?? []; + return ( +
    +

    Runtime configurations

    +

    + Named settings on this Desktop. Keys stay here; edits apply only on the + next Start. +

    + + {agent && local.some((a) => a.pubkey === agent) && ( + + )} +
    + ); +} + +/** Exported mounted seam: scope remount retires every asynchronous continuation. */ +export function RuntimeConfigurationEditor({ + scope, + agent, + runtimes, +}: { + scope: DesktopScope; + agent: string; + runtimes: AcpRuntimeCatalogEntry[] | undefined; +}) { + const [view, setView] = useState(null); + const [draft, setDraft] = useState(null); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [busy, setBusy] = useState(false); + const generation = useRef(0); + const args = { ...scope, agent }; + useEffect(() => { + const token = ++generation.current; + invoke("get_runtime_configurations", { + owner: scope.owner, + community: scope.community, + agent, + }).then( + (next) => { + if (token === generation.current) setView(next); + }, + () => { + if (token === generation.current) + setError("Configurations could not be loaded. Retry."); + }, + ); + return () => { + generation.current++; + }; + }, [scope.owner, scope.community, agent]); + async function perform(work: () => Promise, success = "") { + const token = ++generation.current; + setBusy(true); + setError(""); + setNotice(""); + try { + const next = await work(); + if (token !== generation.current) return; + setView(next); + setDraft(null); + setNotice(success); + } catch { + if (token === generation.current) + setError( + "Operation failed or settings changed. Reload and retry; no new running configuration was confirmed.", + ); + } finally { + if (token === generation.current) setBusy(false); + } + } + const reload = () => + invoke("get_runtime_configurations", args); + const save = (configurations: ConfigurationSet) => + perform( + () => + invoke("save_runtime_configurations", { + ...args, + expectedUpdatedAt: view?.updatedAt, + configurations, + }), + "Saved for next Start. Any running process is unchanged.", + ); + const selected = view?.configurations.entries.find( + (c) => c.id === view.configurations.selected, + ); + const eligible = + view?.catalog.some( + (c) => c.configuration?.id === selected?.id && c.eligible, + ) ?? false; + const runtime = runtimes?.find((r) => r.id === draft?.runtime); + function add() { + if (!view) return; + setDraft({ + id: crypto.randomUUID(), + revision: crypto.randomUUID(), + name: "", + host: view.host, + runtime: "", + model: "", + provider: null, + workspace: null, + credentialRefs: {}, + }); + } + return ( +
    + {error &&

    {error}

    } + {notice &&

    {notice}

    } + + {view && ( + <> +

    + Next Start:{" "} + {selected + ? `${selected.name} · ${selected.runtime} · ${selected.model}` + : "Default (inherited settings)"} +

    +

    + Running revision:{" "} + {view.running + ? `${view.running.id} / ${view.running.revision}` + : "No named launch reported"} +

    + + {!eligible && ( +

    + Start unavailable. Check this agent’s local key, runtime, model + and provider setup, then reload. Existing Stop controls remain + available. +

    + )} + + +
      + {view.configurations.entries.map((c) => ( +
    • + {c.name} · {c.runtime} · {c.model} + + +
    • + ))} +
    + {!runtimes && ( +

    + Runtime catalog unavailable or loading. Reload the catalog before + editing. +

    + )} + {draft && ( +
    + Configuration on this Desktop + + + {runtime?.providerEnvVar && ( + + )} + + +

    + Uses independently provisioned local credentials. No keys are + copied or entered here. An unavailable model fails instead of + silently substituting another. +

    + + +
    + )} + + )} +
    + ); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index b988843d60b..e484122bb67 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -335,7 +335,12 @@ export type ManagedAgent = { systemPrompt: string | null; avatarUrl: string | null; model: string | null; - modelSource: "definition" | "global" | "instance_legacy" | null; + modelSource: + | "definition" + | "global" + | "instance_legacy" + | "runtime_configuration" + | null; /** LLM inference provider, from the agent's pinned record snapshot. */ provider: string | null; /** True when the linked persona has been edited since this agent was created. */ diff --git a/docs/named-runtime-configurations.md b/docs/named-runtime-configurations.md new file mode 100644 index 00000000000..40391e2e6d9 --- /dev/null +++ b/docs/named-runtime-configurations.md @@ -0,0 +1,54 @@ +# Named runtime configurations + +A configuration chooses a host, harness, exact model/provider, optional workspace, +and references to already-provisioned local credentials. Several configurations +can target the same host. Identity, persona and community memory do not move or +change when the next-launch selection changes. + +## Scope and persistence + +`ManagedAgentRecord.runtime_configurations` contains owner → community → set. +The existing agent identity remains global; the sets and Desktop IDs do not. +Absent sets migrate to Default. No unscoped prototype configuration is silently +assigned to the currently open community. Management verifies the active scope +and signed ownership, replaces exactly one set under the store lock, and persists +once. Host-local writes preserve other hosts' entries and reject changing them. +New/changed entries receive a new revision; stale whole-record writes fail. + +The effective resolver only consumes the native launch projection, never guesses +a scope from the record's creation relay. Normal Start captures selection for its +actual owner/community pair. Explicit Start takes an exact `{id, revision}`; +null means Default, not “read selection again later”. The immutable `PreparedLaunch` +is not serializable. The management IPC retains names of local credential +references for editing, but no resolved values. Lifecycle catalogs omit workspace, +credential references, environment and identity material entirely. + +## Launch and actual state + +Preparation checks signed owner, exact local key identity, host, runtime, +workspace and readiness. Ordinary async mesh preflight runs outside the transition +lock. Callers fence owner/community after await, then revalidate the plan under +ordinary admission before shared spawn. Pair registration validates before +reaping an existing child and shared spawn checks again. Stop timestamps alone +do not invalidate a plan; changes to its launch inputs do. Scope changes and +configuration revision changes cannot silently substitute another launch. + +The running pair and durable runtime receipt stamp the launched reference. +Selection and edits never rewrite that receipt. A different already-running +configuration returns Stop-before-Start rather than satisfying the new request. +The existing Stop/generation/authority fences still apply. Ordinary Default Start +keeps its legacy setup behavior; explicit/catalog Default checks readiness. + +A native spawned child is not proof of a successful ACP session or selected model. +Strict model enforcement belongs to ACP's session boundary; no provider model +fallback is an acceptable success for a named selection. Remote lifecycle policy +is independent: this feature does not remove the existing keyless broker gate, +transfer keys, or prove cross-host Move. + +## Regression evidence + +`runtime_configurations/tests.rs` binds migration, scope-preserving writes, +foreign refs/hosts, plan revalidation and shared child spawn. The child is a shell +fixture, not a real model. `RuntimeConfigurations.test.mjs` mounts the editor with +mock IPC and checks exact revision, next-launch-only writes, failure and unmount +fences. Neither test suite constitutes native/model or two-Desktop acceptance. From 7da7c39e64c49a51fadf31e5f0a418f4b1c9591a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 7 Sep 2026 11:44:38 -0400 Subject: [PATCH 32/51] fix(desktop): bind prepared launch inputs and enforce selected ACP model Integrate ACP strict model verification with the named native caller. Bind app session inputs, require declared named MCP tools, and fence Default selection and restore preflight. Local checkpoint: native compilation and async workflow validation remain pending dependency-complete CI; no publication approval assumed. Signed-off-by: Logan Johnson --- crates/buzz-acp/src/lib.rs | 56 +- crates/buzz-acp/src/pool.rs | 528 +++++++++++++++--- desktop/src-tauri/src/commands/agents.rs | 17 +- .../src-tauri/src/managed_agents/discovery.rs | 2 +- .../src-tauri/src/managed_agents/restore.rs | 146 +++-- .../src-tauri/src/managed_agents/runtime.rs | 52 +- .../managed_agents/runtime_configurations.rs | 62 +- .../runtime_configurations/tests.rs | 109 +++- docs/named-runtime-configurations.md | 27 +- 9 files changed, 837 insertions(+), 162 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ddd594b142d..ddb7811a843 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2496,6 +2496,10 @@ async fn tokio_main() -> Result<()> { .init(); let mut config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; + let (startup_model, require_model) = startup_model_selection( + config.model.as_deref(), + std::env::var("BUZZ_ACP_REQUIRED_MODEL"), + )?; // ── Setup-mode early branch ─────────────────────────────────────────────── // @@ -3100,7 +3104,8 @@ async fn tokio_main() -> Result<()> { acp, state: SessionState::default(), model_capabilities: None, - desired_model: config.model.clone(), + desired_model: startup_model.clone(), + require_model, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -5442,10 +5447,29 @@ impl PoolStartup { } } +// Named launches carry the target, not a boolean claiming it was selected. +// This is independent of BUZZ_ACP_MODEL: Claude A1 deliberately removes that +// switch hint and selects through ANTHROPIC_MODEL at launch. Session creation +// must still verify the required target from the adapter's actual response. +fn startup_model_selection( + legacy_model: Option<&str>, + required_model: Result, +) -> Result<(Option, bool)> { + match required_model { + Ok(model) if !model.trim().is_empty() => Ok((Some(model), true)), + Err(std::env::VarError::NotPresent) => Ok((legacy_model.map(str::to_owned), false)), + _ => anyhow::bail!("BUZZ_ACP_REQUIRED_MODEL must be a non-empty UTF-8 model ID"), + } +} + async fn initialize_agent_pool( startup: &PoolStartup, mut shutdown: Option>, ) -> Result { + let (desired_model, require_model) = startup_model_selection( + startup.model.as_deref(), + std::env::var("BUZZ_ACP_REQUIRED_MODEL"), + )?; // 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); @@ -5502,7 +5526,8 @@ async fn initialize_agent_pool( acp, state: SessionState::default(), model_capabilities: None, - desired_model: startup.model.clone(), + desired_model: desired_model.clone(), + require_model, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -9358,6 +9383,7 @@ mod error_outcome_emission_tests { state: Default::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -11248,3 +11274,29 @@ mod observer_payload_trim_tests { assert!(leaf.contains("[elided")); } } + +#[cfg(test)] +mod startup_model_selection_tests { + use super::startup_model_selection; + use std::env::VarError; + + #[test] + fn required_target_is_independent_of_legacy_switch_hint() { + for legacy in [None, Some("legacy")] { + assert_eq!( + startup_model_selection(legacy, Ok("chosen".into())).unwrap(), + (Some("chosen".into()), true), + ); + assert_eq!( + startup_model_selection(legacy, Err(VarError::NotPresent)).unwrap(), + (legacy.map(str::to_owned), false), + ); + } + for value in ["", " "] { + assert!(startup_model_selection(Some("legacy"), Ok(value.into())).is_err()); + } + assert!( + startup_model_selection(None, Err(VarError::NotUnicode("invalid".into()))).is_err() + ); + } +} diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 06383d456d3..355b11aab19 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -236,6 +236,9 @@ pub struct OwnedAgent { pub model_capabilities: Option, /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`. pub desired_model: Option, + /// Require session evidence for `desired_model` before any prompt. Set only + /// for explicit named launches; legacy consumers retain best-effort switching. + pub require_model: bool, /// Whether `desired_model` was set by a live `SwitchModel` control signal /// (as opposed to being derived from config/persona at spawn). Used by the /// desktop reader to distinguish a genuine runtime override from a stale @@ -1390,6 +1393,17 @@ async fn create_session_and_apply_model( ctx.session_title.as_deref(), ); + if agent.require_model + && agent + .desired_model + .as_deref() + .is_none_or(|model| model.trim().is_empty()) + { + return Err(AcpError::Protocol( + "Selected runtime requires a non-empty model; refusing fallback".into(), + )); + } + let resp = agent .acp .session_new_full( @@ -1436,8 +1450,9 @@ async fn create_session_and_apply_model( // Apply desired_model if set, matching against the fresh session/new // response. `post_switch_snapshot` drives everything downstream: - // `Some(value)` → a switch applied; `value` is the adapter's post-switch - // RPC response, whose `configOptions` describe the target + // `Some(value)` → target confirmed at launch, or a switch applied; + // `value` is the corresponding adapter snapshot. Its + // `configOptions` describe the target // model. Effort resolution and the Desktop capture both // read it so they converge on the model the session is // actually running, not the pre-switch default. @@ -1448,98 +1463,152 @@ async fn create_session_and_apply_model( agent.desired_model { // Consume the busy-path pending-ack once for this apply: only the - // `Applied` arm turns it into a positive terminal; the rejection and + // confirmed-current/`Applied` arms emit a positive terminal; rejection and // unsupported arms already emit their own correlated failure frame, so // taking it here keeps a leftover flag from firing a spurious success // on some later unrelated session. let pending_ack = std::mem::take(&mut agent.desired_model_pending_ack); - match resolve_model_switch_method(&resp.raw, desired) { - Some(method) => { - match apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await? - { - ModelSwitchOutcome::Applied(switch_result) => { - // The adapter rebuilds `session.configOptions` for the - // target model and echoes them here. Refresh capabilities - // from that authoritative snapshot when present so the - // idle-switch guard and the panel reflect the target - // model; drop to `None` (re-derive next session) when the - // adapter returned no options so a pre-switch snapshot is - // never mistaken for the target model's. - if switch_result - .get("configOptions") - .is_some_and(|v| !v.is_null()) - { - agent.model_capabilities = Some(AgentModelCapabilities { - config_options_raw: extract_model_config_options(&switch_result), - available_models_raw: extract_model_state(&switch_result), - thought_level_config_id: extract_thought_level_config_id( + // Launch-configured models (Claude A1, Goose, buzz-agent) need no RPC + // when the fresh session already reports the exact target. A launch + // environment or catalog membership alone is not evidence of selection. + if agent.require_model && session_reports_model(&resp.raw, desired) { + if pending_ack { + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", "status": "switched", + "modelId": desired, "requestId": agent.desired_model_request_id, + }), + ); + } + Some(resp.raw.clone()) + } else { + match resolve_model_switch_method(&resp.raw, desired) { + Some(method) => { + match apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method) + .await? + { + ModelSwitchOutcome::Applied(switch_result) => { + if agent.require_model + && !switch_reports_model( &switch_result, - ), - }); - } else { - agent.model_capabilities = None; + &resp.session_id, + desired, + &method, + ) + { + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", "status": "failure", + "modelId": desired, "requestId": agent.desired_model_request_id, + }), + ); + return Err(AcpError::Protocol( + concat!( + "Selected runtime model was not confirmed by the adapter; ", + "refusing fallback" + ) + .into(), + )); + } + // The adapter rebuilds `session.configOptions` for the + // target model and echoes them here. Refresh capabilities + // from that authoritative snapshot when present so the + // idle-switch guard and the panel reflect the target + // model; drop to `None` (re-derive next session) when the + // adapter returned no options so a pre-switch snapshot is + // never mistaken for the target model's. + if switch_result + .get("configOptions") + .is_some_and(|v| !v.is_null()) + { + agent.model_capabilities = Some(AgentModelCapabilities { + config_options_raw: extract_model_config_options( + &switch_result, + ), + available_models_raw: extract_model_state(&switch_result), + thought_level_config_id: extract_thought_level_config_id( + &switch_result, + ), + }); + } else { + agent.model_capabilities = None; + } + // Busy-path deferred switch: emit a positive terminal so + // the Desktop confirms success from a real frame instead + // of inferring it from timeout silence. Gated on the + // pending-ack flag so the idle path (which already acked + // `switched` immediately) does not double-emit. + if pending_ack { + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "switched", + "modelId": desired, + "requestId": agent.desired_model_request_id, + }), + ); + } + Some(switch_result) } - // Busy-path deferred switch: emit a positive terminal so - // the Desktop confirms success from a real frame instead - // of inferring it from timeout silence. Gated on the - // pending-ack flag so the idle path (which already acked - // `switched` immediately) does not double-emit. - if pending_ack { + ModelSwitchOutcome::Rejected => { + // The adapter explicitly rejected the switch: the session + // is still on its default model. Surface a terminal + // failure so the Desktop ModelPicker rejects the live pick + // instead of falsely reporting success, and preserve the + // pre-switch capabilities the session is really running. agent.acp.observe( "control_result", serde_json::json!({ "type": "switch_model", - "status": "switched", + "status": "failure", "modelId": desired, + // Echo the pick's request_id so the Desktop can + // correlate this late frame to the operation + // that fired it, and ignore replayed results. "requestId": agent.desired_model_request_id, }), ); + if agent.require_model { + return Err(AcpError::Protocol( + "Selected runtime model was rejected; refusing fallback".into(), + )); + } + None } - Some(switch_result) } - ModelSwitchOutcome::Rejected => { - // The adapter explicitly rejected the switch: the session - // is still on its default model. Surface a terminal - // failure so the Desktop ModelPicker rejects the live pick - // instead of falsely reporting success, and preserve the - // pre-switch capabilities the session is really running. - agent.acp.observe( - "control_result", - serde_json::json!({ - "type": "switch_model", - "status": "failure", - "modelId": desired, - // Echo the pick's request_id so the Desktop can - // correlate this late frame to the operation - // that fired it, and ignore replayed results. - "requestId": agent.desired_model_request_id, - }), + } + None => { + if !agent.require_model { + tracing::warn!( + target: "pool::model", + "desired model {desired} not found in agent's available models — proceeding with agent default" ); - None } + // Surface the miss so the desktop ModelPicker can reject a live + // pick rather than silently no-op. On the busy path the turn has + // already been cancelled+requeued by the time we get here, so the + // turn restarts on the unchanged model and the user is told no. + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "unsupported_model", + "modelId": desired, + // Echo the pick's request_id (see the failure arm). + "requestId": agent.desired_model_request_id, + }), + ); + if agent.require_model { + return Err(AcpError::Protocol( + "Selected runtime model is unavailable; refusing fallback".into(), + )); + } + None } } - None => { - tracing::warn!( - target: "pool::model", - "desired model {desired} not found in agent's available models — proceeding with agent default" - ); - // Surface the miss so the desktop ModelPicker can reject a live - // pick rather than silently no-op. On the busy path the turn has - // already been cancelled+requeued by the time we get here, so the - // turn restarts on the unchanged model and the user is told no. - agent.acp.observe( - "control_result", - serde_json::json!({ - "type": "switch_model", - "status": "unsupported_model", - "modelId": desired, - // Echo the pick's request_id (see the failure arm). - "requestId": agent.desired_model_request_id, - }), - ); - None - } } } else { None @@ -1640,6 +1709,57 @@ fn mcp_servers_with_git_origin( servers } +// Prefer no guessed aliases: IDs must match exactly. If both stable and +// unstable state are reported they must agree; an available-model catalog is +// not current-model evidence. This also accepts launch-only adapters with no +// switching catalog at all. +fn session_reports_model(snapshot: &serde_json::Value, desired: &str) -> bool { + let options = extract_model_config_options(snapshot); + let mut current = options + .iter() + .map(|option| option.get("currentValue").and_then(|value| value.as_str())) + .chain( + snapshot + .get("models") + .filter(|models| !models.is_null()) + .map(|models| { + models + .get("currentModelId") + .and_then(|value| value.as_str()) + }), + ) + .peekable(); + current.peek().is_some() && current.all(|model| model == Some(desired)) +} + +fn switch_reports_model( + snapshot: &serde_json::Value, + session_id: &str, + desired: &str, + method: &ModelSwitchMethod, +) -> bool { + if snapshot + .get("sessionId") + .is_some_and(|id| id.as_str() != Some(session_id)) + || snapshot + .get("modelId") + .is_some_and(|id| id.as_str() != Some(desired)) + { + return false; + } + if session_reports_model(snapshot, desired) { + return true; + } + // buzz-agent's set_model_session sets effective_model before returning this + // explicit receipt. Do not extend that evidence to an empty/ok-only reply, + // or let it override contradictory configOptions/models state. + matches!(method, ModelSwitchMethod::SetModel { .. }) + && extract_model_config_options(snapshot).is_empty() + && snapshot.get("models").is_none_or(|models| models.is_null()) + && snapshot.get("modelId").and_then(|value| value.as_str()) == Some(desired) + && snapshot.get("sessionId").and_then(|value| value.as_str()) == Some(session_id) +} + /// Outcome of a live model-switch RPC returned by [`apply_model_switch`]. /// /// `Applied` and `Rejected` are distinct outcomes and must not be collapsed: @@ -1723,7 +1843,7 @@ async fn apply_model_switch( Ok(Err(e)) => { tracing::warn!( target: "pool::model", - "failed to set model {desired} via {method_label}: {e} — proceeding with agent default" + "adapter rejected model {desired} via {method_label}: {e}" ); Ok(ModelSwitchOutcome::Rejected) } @@ -6575,6 +6695,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -6675,6 +6796,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -6853,6 +6975,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -7007,6 +7130,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -7450,6 +7574,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -8417,6 +8542,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -8478,6 +8604,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -9593,6 +9720,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -9987,6 +10115,7 @@ mod startup_effort_tests { state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -10247,6 +10376,7 @@ mod model_switch_tests { state: SessionState::default(), model_capabilities: None, desired_model: Some(desired_model.to_string()), + require_model: false, model_overridden: true, desired_model_request_id: None, desired_model_pending_ack: false, @@ -10262,13 +10392,21 @@ mod model_switch_tests { /// `switch_reply` (a JSON-RPC `result`/`error` body minus the id). Any later /// request gets `{"ok":true}`. async fn spawn_switch_acp(session_new_options: &str, switch_reply: &str) -> AcpClient { + spawn_switch_snapshot_acp( + &format!(r#"{{"sessionId":"sess-1","configOptions":{session_new_options}}}"#), + switch_reply, + ) + .await + } + + async fn spawn_switch_snapshot_acp(session_new: &str, switch_reply: &str) -> AcpClient { let script = format!( r#"count=0 while IFS= read -r line; do count=$((count + 1)) id=$((count - 1)) if [ "$count" -eq 1 ]; then - printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{session_new_options}}}}}' + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{session_new}}}' elif [ "$count" -eq 2 ]; then printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{switch_reply}}}' else @@ -10301,6 +10439,243 @@ done"# // agent wants to switch to. const OPTS_MODEL_A_AND_B: &str = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#; + async fn create_test_session(agent: &mut OwnedAgent) -> Result { + create_session_and_apply_model( + agent, + &make_prompt_context_no_owner(), + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + scope: None, + channel_type: None, + }, + ) + .await + } + + #[test] + fn required_model_evidence_rejects_missing_and_conflicting_state() { + for (snapshot, matches) in [ + (serde_json::json!({}), false), + ( + serde_json::json!({"models":{"availableModels":[{"modelId":"model-b"}]}}), + false, + ), + ( + serde_json::json!({"models":{"currentModelId":"model-b"}}), + true, + ), + ( + serde_json::json!({"configOptions":[{"category":"model","currentValue":"model-b"}]}), + true, + ), + ( + serde_json::json!({"models":{"currentModelId":"model-a"}, + "configOptions":[{"category":"model","currentValue":"model-b"}]}), + false, + ), + ] { + assert_eq!(session_reports_model(&snapshot, "model-b"), matches); + } + let method = ModelSwitchMethod::SetModel { + model_id: "model-b".into(), + }; + for (snapshot, matches) in [ + ( + serde_json::json!({"sessionId":"sess-1","modelId":"model-b"}), + true, + ), + ( + serde_json::json!({"sessionId":"other","modelId":"model-b"}), + false, + ), + (serde_json::json!({"modelId":"model-b"}), false), + ( + serde_json::json!({"sessionId":"sess-1","modelId":"model-b","models":{"currentModelId":"model-a"}}), + false, + ), + ( + serde_json::json!({"sessionId":"sess-1","modelId":"model-a","models":{"currentModelId":"model-b"}}), + false, + ), + ] { + assert_eq!( + switch_reports_model(&snapshot, "sess-1", "model-b", &method), + matches + ); + } + } + + #[tokio::test] + async fn required_model_switch_requires_actual_confirmation() { + for (reply, succeeds) in [ + ( + r#""result":{"configOptions":[{"id":"model","category":"model","currentValue":"model-b"}]}"#, + true, + ), + ( + r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-a"}]}"#, + false, + ), + (r#""result":{"ok":true}"#, false), + (r#""error":{"code":-32602,"message":"rejected"}"#, false), + ] { + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, reply).await; + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + agent.desired_model_pending_ack = true; + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + assert_eq!(create_test_session(&mut agent).await.is_ok(), succeeds); + let results = control_results(&obs); + assert_eq!(results.len(), 1); + assert_eq!( + results[0]["status"], + if succeeds { "switched" } else { "failure" } + ); + assert_eq!( + obs.snapshot() + .iter() + .any(|e| e.kind == "session_config_captured"), + succeeds + ); + assert!(!obs + .snapshot() + .iter() + .any(|e| e.kind == "acp_write" && e.payload["method"] == "session/prompt")); + agent.acp.shutdown().await; + } + } + + #[tokio::test] + async fn required_model_missing_or_unavailable_never_falls_back() { + for desired in [None, Some(""), Some("model-unavailable")] { + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{}"#).await; + let mut agent = switching_agent(acp, "unused"); + agent.desired_model = desired.map(str::to_owned); + agent.require_model = true; + assert!(matches!( + create_test_session(&mut agent).await, + Err(AcpError::Protocol(_)) + )); + agent.acp.shutdown().await; + } + } + + #[tokio::test] + async fn required_model_accepts_launch_selection_without_switch_catalog() { + // Same launch authority as Claude A1; the child reports the model it + // actually read from launch env. No switching API/catalog is offered. + let script = r#"id=0 +while IFS= read -r line; do +printf '%s\n' '{"jsonrpc":"2.0","id":'"$id"',"result":{"sessionId":"sess-1","configOptions":[{"id":"model","category":"model","currentValue":"'"$ANTHROPIC_MODEL"'"}]}}' +id=$((id + 1)) +done"#; + let acp = AcpClient::spawn( + "bash", + &["-c".into(), script.into()], + &[("ANTHROPIC_MODEL".into(), "model-b".into())], + false, + ) + .await + .unwrap(); + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + agent.desired_model_pending_ack = true; + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + // Re-check every fresh session, not just the first cached capability set. + for _ in 0..2 { + assert!(create_test_session(&mut agent).await.is_ok()); + } + assert_eq!(capture(&obs)["configOptions"][0]["currentValue"], "model-b"); + assert_eq!(control_results(&obs).len(), 1); + assert!(obs + .snapshot() + .iter() + .filter(|e| e.kind == "acp_write") + .all(|e| e.payload["method"] == "session/new")); + agent.acp.shutdown().await; + } + + #[tokio::test] + async fn required_model_uses_buzz_agent_session_and_switch_receipts() { + // Shapes emitted by buzz-agent::session_new and set_model_session. + for (current, reply, succeeds) in [ + ( + "model-b", + r#""error":{"code":-32601,"message":"no switch API"}"#, + true, + ), + ( + "model-a", + r#""result":{"sessionId":"sess-1","modelId":"model-b"}"#, + true, + ), + ("model-a", r#""result":{}"#, false), + ( + "model-a", + r#""result":{"sessionId":"sess-1","modelId":"model-a"}"#, + false, + ), + ] { + let raw = serde_json::json!({"sessionId":"sess-1", "models": { + "currentModelId":current, "availableModels":[{"modelId":"model-b"}] + }}); + let acp = spawn_switch_snapshot_acp(&raw.to_string(), reply).await; + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + assert_eq!(create_test_session(&mut agent).await.is_ok(), succeeds); + agent.acp.shutdown().await; + } + } + + #[tokio::test] + async fn required_model_rejects_unverified_launch_and_transport_failure() { + for raw in [ + serde_json::json!({"sessionId":"sess-1"}), + serde_json::json!({"sessionId":"sess-1","models":{"currentModelId":"model-a"}}), + serde_json::json!({"sessionId":"sess-1","models":{"currentModelId":"model-b"}, + "configOptions":[{"category":"model","currentValue":"model-a"}]}), + ] { + let acp = spawn_switch_snapshot_acp(&raw.to_string(), r#""result":{}"#).await; + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + assert!(create_test_session(&mut agent).await.is_err()); + agent.acp.shutdown().await; + } + let script = format!( + r#"IFS= read -r line +printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{OPTS_MODEL_A_AND_B}}}}}' +IFS= read -r line +exit 0"# + ); + let acp = AcpClient::spawn("bash", &["-c".into(), script], &[], false) + .await + .unwrap(); + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + assert!(create_test_session(&mut agent).await.is_err()); + agent.acp.shutdown().await; + } + + #[tokio::test] + async fn default_model_retains_legacy_best_effort_behavior() { + for (desired, reply) in [ + ("unavailable", r#""result":{}"#), + ("model-b", r#""error":{"code":-32602,"message":"rejected"}"#), + ("model-b", r#""result":{}"#), + ] { + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, reply).await; + let mut agent = switching_agent(acp, desired); + assert!(!agent.require_model); + assert!(create_test_session(&mut agent).await.is_ok()); + agent.acp.shutdown().await; + } + } + #[tokio::test] async fn session_new_sends_policy_specific_base_and_scope_specific_title() { use crate::scope::SessionPolicy; @@ -10822,6 +11197,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: Some(desired_model.to_string()), + require_model: false, model_overridden: true, desired_model_request_id: None, desired_model_pending_ack: false, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 9ec2dfae44e..33be04454f8 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -300,16 +300,15 @@ async fn start_local_agent_with_preflight( if record.backend != BackendKind::Local { return Err(format!("agent {pubkey} is no longer a local agent")); } + if requested.is_none() { + crate::managed_agents::runtime_configurations::check_selection( + record, + Some(&launch_owner), + launch_relay.as_str(), + configuration.as_ref(), + )?; + } if let Some(plan) = &prepared { - if requested.is_none() - && crate::managed_agents::runtime_configurations::selected_reference( - record, - Some(&launch_owner), - launch_relay.as_str(), - )? != configuration - { - return Err("Selected configuration changed during preflight".into()); - } plan.revalidate( record, &load_personas(app)?, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 5fd7d7ed987..f6c8ceff9fd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -383,7 +383,7 @@ fn command_search_dirs() -> Vec { }) } -fn is_executable_file(path: &Path) -> bool { +pub(super) fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; }; diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 05e8b822e6a..b6a6e482cb8 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,8 +1,7 @@ use super::{ bestie_assignment::recover_pending_assignment_cleanup, find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, managed_agents_base_dir, - save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, - ManagedAgentProcess, + save_managed_agents, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; use crate::app_state::AppState; use crate::util; @@ -250,38 +249,53 @@ pub async fn restore_managed_agents_on_launch( .ok() .map(|k| k.public_key().to_hex()); - #[cfg(feature = "mesh-llm")] - let agents_to_start = { - // 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. See `start_local_agent_with_preflight` - // in `commands/agents.rs` for the identical rationale on the interactive path. - let personas = load_personas(app).unwrap_or_default(); - let global = super::load_global_agent_config(app).unwrap_or_default(); - let mut mesh_preflight_failures = std::collections::HashSet::new(); - for record in &agents_to_start { - let mesh_model_id = super::effective_config::resolve_effective_relay_mesh_model_id( - record, &personas, &global, - ); - if mesh_model_id.is_none() { - continue; - } - // Auto-start after relaunch: re-resolve a live bootstrap target and - // dial it. Skip (with an actionable error) only when no live target - // serves this model right now. - if let Err(error) = - crate::commands::ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false) - .await - { - persist_restore_error(app, &state, &record.pubkey, error)?; - mesh_preflight_failures.insert(record.pubkey.clone()); + // Capture the actual scoped selection before awaiting, never preflight Default + // and recapture a different selected model at spawn. + let launch_relay = crate::relay::relay_ws_url_with_override(&state); + let mut prepared_agents = Vec::new(); + for record in agents_to_start { + let preparation = super::runtime_configurations::prepare_selected( + app, + &record, + owner_hex.as_deref(), + &launch_relay, + ); + let result = async { + let plan = preparation?; + if let Some(plan) = &plan { + super::runtime_configurations::preflight_prepared( + app, + plan, + owner_hex.as_deref().ok_or("Desktop owner unavailable")?, + &launch_relay, + ) + .await?; + } else { + #[cfg(feature = "mesh-llm")] + { + let model = super::effective_config::resolve_effective_relay_mesh_model_id( + &record, + &load_personas(app)?, + &super::load_global_agent_config(app)?, + ); + crate::commands::ensure_relay_mesh_for_record(app, model.as_deref(), false) + .await?; + } } + Ok::<_, String>(plan) } - agents_to_start - .into_iter() - .filter(|record| !mesh_preflight_failures.contains(&record.pubkey)) - .collect::>() - }; + .await; + match result { + Ok(plan) => prepared_agents.push((record, plan)), + Err(error) => persist_restore_error(app, &state, &record.pubkey, error)?, + } + } + let agents_to_start = prepared_agents; + if crate::relay::relay_ws_url_with_override(&state) != launch_relay + || state.signing_keys()?.public_key().to_hex() != owner_hex.as_deref().unwrap_or("") + { + return Err("Desktop scope changed during restore preflight".into()); + } if agents_to_start.is_empty() { return Ok(()); } @@ -301,17 +315,13 @@ pub async fn restore_managed_agents_on_launch( // ── Phase B (transition lock held): resolve commands and spawn in parallel ── let spawn_results: Vec = std::thread::scope(|scope| { let owner_hex_ref = owner_hex.as_deref(); + let state = &state; let handles: Vec<_> = agents_to_start .iter() .filter(|_| !shutdown_started.load(Ordering::SeqCst)) - .map(|record| { + .map(|(record, prepared)| { + let relay_url = launch_relay.clone(); let handle = scope.spawn(move || { - let workspace_relay = - crate::relay::relay_ws_url_with_override(&app.state::()); - let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, - &workspace_relay, - ); let outcome = match super::ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url) { @@ -334,24 +344,46 @@ pub async fn restore_managed_agents_on_launch( if already_live { SpawnOutcome::Skipped } else { - match super::terminate_untracked_pair_runtime(app, &key) - .and_then(|()| { - // F1: restore spawns lazy, matching - // reconcile and manual start. Eager on - // restore buys nothing — a crashed - // mid-turn session is not resumed by an - // eager child — and silently reintroduces - // N idle brains on every launch. - spawn_agent_child( - app, - record, - &relay_url, - true, - owner_hex_ref, - None, - None, - ) - }) { + let result = (|| { + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let current = records + .iter() + .find(|r| r.pubkey == record.pubkey) + .ok_or("Agent removed during restore preflight")?; + super::runtime_configurations::check_selection( + current, + owner_hex_ref, + &relay_url, + prepared + .as_ref() + .and_then(|plan| plan.configuration()) + .as_ref(), + )?; + if let Some(plan) = prepared { + plan.revalidate( + current, + &load_personas(app)?, + &super::load_global_agent_config(app)?, + )?; + } + super::terminate_untracked_pair_runtime(app, &key)?; + super::runtime::spawn_agent_child_with_broker( + app, + current, + &relay_url, + true, + owner_hex_ref, + None, + None, + None, + prepared.as_ref(), + ) + })(); + match result { Ok(process) => { SpawnOutcome::Spawned(key, relay_url, Box::new(process)) } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 930f640d6cc..c73bddedadd 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -565,6 +565,18 @@ pub(crate) fn spawn_agent_child_with_broker( }; let effective_command = &descriptor.command; let agent_args = &descriptor.args; + let app_inputs = prepared + .map(|plan| { + plan.app_inputs + .as_ref() + .ok_or("Launch plan has no app inputs") + }) + .transpose()?; + let required_mcp = if super::runtime_configurations::selected(record)?.is_some() { + super::runtime_configurations::required_mcp_command(effective_command)? + } else { + None + }; let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( @@ -688,7 +700,10 @@ pub(crate) fn spawn_agent_child_with_broker( } } } - let team_instructions = super::spawn_snapshot::effective_team_instructions(record, &teams); + let team_instructions = match app_inputs { + Some((instructions, _)) => instructions.clone(), + None => super::spawn_snapshot::effective_team_instructions(record, &teams), + }; if let Some(instructions) = &team_instructions { command.env("BUZZ_ACP_TEAM_INSTRUCTIONS", instructions); } else { @@ -820,8 +835,15 @@ pub(crate) fn spawn_agent_child_with_broker( for (key, value) in &descriptor.env { command.env(key, value); } - // Resolve once and stamp the same value onto the snapshot below. - let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command); + // Prepared launches bind session partitioning before async preflight; Default + // retains the existing launch-time experiment policy. Stamp exactly what we apply. + let acp_session_policy = match app_inputs { + Some((_, policy)) => { + super::session_policy::apply_acp_session_policy_env(&mut command, *policy); + *policy + } + None => super::apply_app_acp_session_policy_env(app, &mut command), + }; crate::build_identity::apply_demo_config_home(&mut command)?; // Publish-first replay floor: written AFTER the `descriptor.env` loop, the @@ -903,13 +925,27 @@ pub(crate) fn spawn_agent_child_with_broker( )?; } // Applied last so inherited environment cannot weaken explicit model selection. - command.env_remove("BUZZ_ACP_REQUIRE_MODEL"); - if let Some(config) = super::runtime_configurations::selected(record)? { - command.env("BUZZ_ACP_REQUIRE_MODEL", "true"); - if let Some(workspace) = &config.workspace { - command.current_dir(workspace); + let configuration = super::runtime_configurations::selected(record)?; + let required_model = configuration + .map(|_| { + acp_model + .as_deref() + .ok_or("Named launch has no resolved model") + }) + .transpose()?; + super::runtime_configurations::apply_required_model_env(&mut command, required_model)?; + if let Some(model) = required_model { + if !runtime_meta.is_some_and(|runtime| runtime.id == "claude") { + command.env("BUZZ_ACP_MODEL", model); } } + if let Some(mcp) = required_mcp { + // A saved environment cannot replace or disable a declared named tool. + command.env("BUZZ_ACP_MCP_COMMAND", mcp); + } + if let Some(workspace) = configuration.and_then(|config| config.workspace.as_ref()) { + command.current_dir(workspace); + } let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs index 13b171bc19a..e30dff65fed 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs @@ -43,10 +43,15 @@ pub(crate) struct PreparedLaunch { pub(super) effective: super::effective_config::EffectiveAgentConfig, host: String, scope: (String, String), + // Catalog-only preparation has no app context and cannot be executed. + pub(super) app_inputs: Option<(Option, super::AcpSessionPolicy)>, } impl PreparedLaunch { pub(crate) fn check_scope(&self, owner: Option<&str>, community: &str) -> Result<(), String> { + if self.app_inputs.is_none() { + return Err("Launch plan has no app inputs".into()); + } if Some(self.scope.0.as_str()) != owner || self.scope.1 != community { return Err("Prepared runtime launch belongs to another owner or community".into()); } @@ -133,6 +138,7 @@ pub(crate) fn prepare( effective, host: host.into(), scope: (owner.into(), community.into()), + app_inputs: None, }) } @@ -279,6 +285,9 @@ pub(crate) fn preflight( if super::resolve_command(&descriptor.command).is_none() { return Err("Selected runtime is unavailable on this Desktop".into()); } + if config.is_some() { + required_mcp_command(&descriptor.command)?; + } if config .and_then(|c| c.workspace.as_ref()) .is_some_and(|path| !std::path::Path::new(path).is_dir()) @@ -321,8 +330,9 @@ pub(crate) fn prepare_for_app( owner: &str, community: &str, ) -> Result { + use tauri::Manager; let host = local_host(app, owner, community)?; - prepare( + let mut plan = prepare( record, reference, &super::load_personas(app)?, @@ -330,7 +340,12 @@ pub(crate) fn prepare_for_app( &host, owner, community, - ) + )?; + plan.app_inputs = Some(( + super::spawn_snapshot::effective_team_instructions(record, &super::load_teams(app)?), + super::acp_session_policy(app.state::().inner()), + )); + Ok(plan) } fn local_host( @@ -420,6 +435,19 @@ pub(crate) fn catalog( .collect() } +/// Fence ordinary next-launch selection, including Default, across preflight. +pub(crate) fn check_selection( + record: &ManagedAgentRecord, + owner: Option<&str>, + community: &str, + expected: Option<&RuntimeConfigurationRef>, +) -> Result<(), String> { + if selected_reference(record, owner, community)?.as_ref() != expected { + return Err("Selected configuration changed during preflight".into()); + } + Ok(()) +} + /// Validate or resolve before any existing pair is reaped. The shared spawn checks again. pub(crate) fn prepare_selected( app: &tauri::AppHandle, @@ -480,5 +508,35 @@ pub(crate) async fn preflight_prepared( Ok(()) } +/// Named runtime selection includes its catalog-declared MCP tool server. Unlike +/// Default's optional inherited capability, an unavailable declared tool is an error. +pub(crate) fn required_mcp_command(command: &str) -> Result, String> { + super::known_acp_runtime(command) + .and_then(|runtime| runtime.mcp_command) + .map(|command| { + super::resolve_command(command) + .filter(|path| super::discovery::is_executable_file(path)) + .ok_or_else(|| "Selected runtime's required MCP tool is unavailable".to_string()) + }) + .transpose() +} + +/// Final authority after all inherited/harness/mesh environment writes. This is +/// an ACP verification target, not an assertion that the model is ready. +pub(crate) fn apply_required_model_env( + command: &mut std::process::Command, + required: Option<&str>, +) -> Result<(), String> { + command.env_remove("BUZZ_ACP_REQUIRE_MODEL"); + command.env_remove("BUZZ_ACP_REQUIRED_MODEL"); + if let Some(model) = required { + if model.trim().is_empty() { + return Err("Named launch has no resolved model".into()); + } + command.env("BUZZ_ACP_REQUIRED_MODEL", model); + } + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs index 051f04a2cc4..8727dc7c6bd 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs @@ -251,7 +251,7 @@ fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { agents::clear_resolve_cache(); let capture = temp.path().join("capture"); // A fixture child at the real spawn boundary, not an ACP/session or live-model claim. - for (name, script) in [("buzz-agent", "#!/bin/sh\nexit 0\n".to_string()), ("buzz-acp", format!("#!/bin/sh\nprintf '%s\\n' \"$BUZZ_ACP_MODEL\" \"$BUZZ_AGENT_MODEL\" \"$BUZZ_AGENT_PROVIDER\" \"$BUZZ_ACP_REQUIRE_MODEL\" \"$PWD\" > '{}'\n", capture.display()))] { + for (name, script) in [("buzz-agent", "#!/bin/sh\nexit 0\n".to_string()), ("buzz-dev-mcp", "#!/bin/sh\nexit 0\n".to_string()), ("buzz-acp", format!("#!/bin/sh\nprintf '%s\\n' \"$BUZZ_ACP_MODEL\" \"$BUZZ_AGENT_MODEL\" \"$BUZZ_AGENT_PROVIDER\" \"$BUZZ_ACP_REQUIRED_MODEL\" \"$PWD\" \"$BUZZ_ACP_TEAM_INSTRUCTIONS\" \"$BUZZ_ACP_SESSION_POLICY\" \"$BUZZ_ACP_MCP_COMMAND\" \"$BUZZ_ACP_REQUIRE_MODEL\" > '{}'\n", capture.display()))] { let path = temp.path().join(name); std::fs::write(&path, script).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); @@ -303,6 +303,18 @@ fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { let mut named = config(&host); named.workspace = Some(temp.path().display().to_string()); let named = save(&mut record, &owner, community, named); + let mut teams = agents::load_teams(app.handle()).unwrap(); + teams[0].instructions = Some("prepared team instructions".into()); + record.team_id = Some(teams[0].id.clone()); + agents::save_teams(app.handle(), &teams).unwrap(); + for (key, value) in [ + ("BUZZ_ACP_MODEL", "wrong-inherited-model"), + ("BUZZ_ACP_REQUIRE_MODEL", "false"), + ("BUZZ_ACP_REQUIRED_MODEL", "wrong-inherited-model"), + ("BUZZ_ACP_MCP_COMMAND", "wrong-inherited-tool"), + ] { + record.env_vars.insert(key.into(), value.into()); + } let plan = prepare_for_app( app.handle(), &record, @@ -311,6 +323,13 @@ fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { community, ) .unwrap(); + // Team content and session partitioning changed while preflight was awaiting. + // This launch uses the prepared values; a later preparation sees the edits. + teams[0].instructions = Some("next launch instructions".into()); + agents::save_teams(app.handle(), &teams).unwrap(); + app.state::() + .thread_scoped_acp_sessions_enabled() + .store(true, std::sync::atomic::Ordering::Release); assert_eq!( selected_reference(&record, Some(&owner), community).unwrap(), Some(named.reference()) @@ -346,6 +365,35 @@ fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { assert!(!safe.contains("credentialRefs")); assert!(!safe.contains("workspace")); assert!(!safe.contains("fixture-not-a-secret")); + let next = prepare_for_app( + app.handle(), + &record, + Some(&named.reference()), + &owner, + community, + ) + .unwrap(); + assert_eq!( + next.app_inputs, + Some(( + Some("next launch instructions".into()), + agents::AcpSessionPolicy::Thread + )) + ); + // A cached resolution must not hide a tool removed after preparation. + let mcp_path = required_mcp_command("buzz-agent").unwrap().unwrap(); + assert_eq!(mcp_path, temp.path().join("buzz-dev-mcp")); + std::fs::set_permissions(&mcp_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(plan.revalidate(&record, &[], &Default::default()).is_err()); + assert!( + !catalog(&record, &[], &Default::default(), &host, &owner, community) + .iter() + .find(|entry| entry.configuration == Some(named.reference())) + .unwrap() + .eligible + ); + assert!(required_mcp_command("claude-agent-acp").unwrap().is_none()); + std::fs::set_permissions(&mcp_path, std::fs::Permissions::from_mode(0o700)).unwrap(); let relay = crate::relay::bind_expected_relay_scope(None, community.into()).unwrap(); let mut runtimes = std::collections::HashMap::new(); agents::start_managed_agent_process_prepared( @@ -369,8 +417,9 @@ fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { assert_eq!( std::fs::read_to_string(capture).unwrap(), format!( - "fixture-model\nfixture-model\nopenai\ntrue\n{}\n", - temp.path().display() + "fixture-model\nfixture-model\nopenai\nfixture-model\n{}\nprepared team instructions\nchannel\n{}\n\n", + temp.path().display(), + mcp_path.display() ) ); assert!(agents::read_all_agent_runtime_receipts(app.handle()) @@ -378,3 +427,57 @@ fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { .any(|(_, receipt)| receipt.runtime_configuration == Some(named.reference()))); agents::remove_agent_runtime_receipt(app.handle(), &key); } + +#[test] +fn final_model_authority_clears_inheritance_and_preserves_claude_a1() { + use std::ffi::OsStr; + for required in [None, Some("exact-wire-model")] { + let mut command = std::process::Command::new("fixture"); + command.env("BUZZ_ACP_REQUIRE_MODEL", "false"); + command.env("BUZZ_ACP_REQUIRED_MODEL", "wrong-inherited-model"); + command.env("ANTHROPIC_MODEL", "exact-wire-model"); + command.env_remove("BUZZ_ACP_MODEL"); + apply_required_model_env(&mut command, required).unwrap(); + let env = command.get_envs().collect::>(); + assert_eq!(env[OsStr::new("BUZZ_ACP_REQUIRE_MODEL")], None); + assert_eq!( + env[OsStr::new("BUZZ_ACP_REQUIRED_MODEL")], + required.map(OsStr::new) + ); + assert_eq!(env[OsStr::new("BUZZ_ACP_MODEL")], None); + assert_eq!( + env[OsStr::new("ANTHROPIC_MODEL")], + Some(OsStr::new("exact-wire-model")) + ); + } + for model in ["", " "] { + assert!( + apply_required_model_env(&mut std::process::Command::new("fixture"), Some(model)) + .is_err() + ); + } +} + +#[test] +fn ordinary_selection_fence_includes_default() { + let mut record = record(); + let owner = attest(&mut record, &nostr::Keys::generate()); + assert!(check_selection(&record, Some(&owner), "one", None).is_ok()); + let named = save(&mut record, &owner, "one", config("host")); + assert!(check_selection(&record, Some(&owner), "one", None).is_err()); + assert!(check_selection(&record, Some(&owner), "one", Some(&named.reference())).is_ok()); + assert!(check_selection(&record, Some(&owner), "two", None).is_ok()); + record + .runtime_configurations + .replace( + &owner, + "one", + "host", + RuntimeConfigurations { + selected: None, + entries: vec![named.clone()], + }, + ) + .unwrap(); + assert!(check_selection(&record, Some(&owner), "one", Some(&named.reference())).is_err()); +} diff --git a/docs/named-runtime-configurations.md b/docs/named-runtime-configurations.md index 40391e2e6d9..40c153cb374 100644 --- a/docs/named-runtime-configurations.md +++ b/docs/named-runtime-configurations.md @@ -26,12 +26,26 @@ credential references, environment and identity material entirely. ## Launch and actual state Preparation checks signed owner, exact local key identity, host, runtime, -workspace and readiness. Ordinary async mesh preflight runs outside the transition -lock. Callers fence owner/community after await, then revalidate the plan under +workspace, readiness and the selected runtime's catalog-declared MCP executable. +Codex/buzz-agent declare `buzz-dev-mcp`; Goose/Claude do not declare a separate +server. Named launches refuse a missing/nonexecutable declared tool and reassert +its resolved command after inherited env. Default retains the existing optional +MCP skip behavior; optional git credential helpers are not configuration tools. + +App preparation also binds effective team instructions and channel/thread session +partitioning: both change the session's instruction/context inputs. Edits to these +apply on the next preparation, rather than changing an in-flight preflight. Other +app state (replay floor, process nonce, logging, shutdown/admission fences) remains +invocation/operational policy, not a snapshot of all app state. + +Ordinary async mesh preflight runs outside the transition lock. Callers fence owner/community after await, then revalidate the plan under ordinary admission before shared spawn. Pair registration validates before reaping an existing child and shared spawn checks again. Stop timestamps alone do not invalidate a plan; changes to its launch inputs do. Scope changes and -configuration revision changes cannot silently substitute another launch. +configuration revision changes cannot silently substitute another launch. Ordinary +Default selection is fenced too, without imposing strict readiness on its legacy +setup path. App-launch restore captures/preflights the scoped selection and checks +the current record before terminating untracked pair state. The running pair and durable runtime receipt stamp the launched reference. Selection and edits never rewrite that receipt. A different already-running @@ -41,7 +55,12 @@ keeps its legacy setup behavior; explicit/catalog Default checks readiness. A native spawned child is not proof of a successful ACP session or selected model. Strict model enforcement belongs to ACP's session boundary; no provider model -fallback is an acceptable success for a named selection. Remote lifecycle policy +fallback is an acceptable success for a named selection. The final native env +sets `BUZZ_ACP_REQUIRED_MODEL` to the exact prepared wire ID after inherited, +harness and mesh writes; non-Claude `BUZZ_ACP_MODEL` is pinned too. Default removes +both strict variables. Claude retains A1 (`ANTHROPIC_MODEL`, no `BUZZ_ACP_MODEL`). +ACP requires matching fresh session/current-model or verified switch evidence +before any prompt, not merely catalog membership or a successful child spawn. Remote lifecycle policy is independent: this feature does not remove the existing keyless broker gate, transfer keys, or prove cross-host Move. From eb109b8a19d1c72743452d67f3cc6c170fd1ba62 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 7 Sep 2026 12:20:05 -0400 Subject: [PATCH 33/51] fix(desktop): require captured preflight authority for runtime launches Recover shared Default/named launch capture; cover direct pair Start/Restart, bulk and restore with locked same-plan admission. Classify mesh recovery by running pair snapshots. Route UI Restart through native admission while preserving old-turn cleanup. Native orchestration regressions remain a dependency-complete CI gate; frontend6295 and bounded exact-source policy checks pass. Local checkpoint only; no publication or remote broker-policy change. Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/agents.rs | 301 ++++---- .../src-tauri/src/commands/agents_pending.rs | 4 +- .../managed_agents/effective_config/mod.rs | 14 +- .../src/managed_agents/relay_mesh.rs | 18 + .../src/managed_agents/remote_stop.rs | 6 + .../src-tauri/src/managed_agents/restore.rs | 165 ++-- .../src-tauri/src/managed_agents/runtime.rs | 117 +-- .../src/managed_agents/runtime_commands.rs | 466 ++++++----- .../managed_agents/runtime_configurations.rs | 141 ++-- .../orchestration_tests.rs | 730 ++++++++++++++++++ .../runtime_configurations/tests.rs | 27 +- .../src/managed_agents/spawn_snapshot.rs | 4 + desktop/src-tauri/src/mesh_llm/mod.rs | 4 +- desktop/src-tauri/src/mesh_llm/recovery.rs | 206 ++--- desktop/src/features/agents/AGENTS.md | 10 + .../features/agents/activeAgentTurnsStore.ts | 20 + .../agents/managedAgentRuntimeHooks.test.mjs | 129 ++-- .../agents/managedAgentRuntimeHooks.ts | 66 +- 18 files changed, 1638 insertions(+), 790 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 33be04454f8..2446d6469b7 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -10,11 +10,11 @@ use crate::{ build_managed_agent_summary, current_instance_id, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, managed_agents_base_dir, 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, + save_managed_agents, 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, }, relay::relay_ws_url_with_override, util::now_iso, @@ -37,8 +37,8 @@ pub(crate) use pending::{retain_managed_agent_pending, tombstone_managed_agent_p /// For one-shot command paths only — the 5s list poll calls /// `build_managed_agent_summary` directly with stores loaded once per call, /// not once per record. -pub(super) fn summarize_from_disk( - app: &AppHandle, +pub(super) fn summarize_from_disk( + app: &AppHandle, record: &ManagedAgentRecord, runtimes: &std::collections::HashMap< crate::managed_agents::ManagedAgentRuntimeKey, @@ -83,58 +83,96 @@ pub(super) async fn start_local_agent_pairs_with_preflight( pubkey: &str, relay_urls: &[String], ) -> Result { - let record_snapshot = { - let _store_guard = state + start_local_agent_pairs_with_preflight_using(app, state, pubkey, relay_urls, + |model, allow| async move { ensure_relay_mesh_for_record(app, model.as_deref(), allow).await }).await +} + +pub(crate) async fn start_local_agent_pairs_with_preflight_using( + app: &AppHandle, + state: &AppState, + pubkey: &str, + relay_urls: &[String], + preflight: F, +) -> Result +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + use crate::managed_agents::runtime_configurations as configurations; + let owner = workspace_owner_hex(state)?; + // Snapshot all pairs before the first await. Each community has a private + // selection; a single Default preflight cannot authorize this restart batch. + let plans = { + let _store = 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"))? + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + refresh_launch_persona(app, record)?; + let plans = relay_urls + .iter() + .map(|relay| { + let key = crate::managed_agents::ManagedAgentRuntimeKey::new(pubkey, relay)?; + configurations::capture_for_app(app, record, &owner, &key.relay_url) + .map(|plan| (key.relay_url, plan)) + }) + .collect::, String>>()?; + save_managed_agents(app, &records)?; + retain_managed_agent_pending(app, state, find_managed_agent_mut(&mut records, pubkey)?); + plans }; - if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); + let mut errors = Vec::new(); + let mut ready = Vec::new(); + for (relay, mut plan) in plans { + match configurations::preflight_with(&mut plan, &owner, &relay, false, &preflight).await { + Ok(()) => ready.push((relay, plan)), + Err(error) => errors.push(format!("{relay}: {error}")), + } } - 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 _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + crate::relay::bind_expected_signer(Some(&owner), workspace_owner_hex(state)?)?; + // Check the batch's Stop marker once before any pair writes record-level + // lifecycle bookkeeping. Each successful pair clears last_stopped_at, which + // must not invalidate the remaining communities in this same locked batch. { - let _store_guard = state + 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)?; - 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 records = load_managed_agents(app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("Agent removed during preflight")?; + for (_, plan) in &ready { + plan.check_continuation(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(), + for (relay, plan) in ready { + // Selection and prerequisites are checked under the same store lock as + // termination/spawn, with the exact captured plan (including Default). + if let Err(error) = crate::managed_agents::start_pair_captured_locked( + pubkey.into(), + relay.clone(), + true, + None, + None, + None, + &plan, + true, + false, + None, app.clone(), ) { - errors.push(format!("{relay_url}: {error}")); + errors.push(format!("{relay}: {error}")); } } if !errors.is_empty() { @@ -143,8 +181,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight( errors.join("; ") )); } - - let _store_guard = state + let _store = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; @@ -160,7 +197,23 @@ pub(super) async fn start_local_agent_pairs_with_preflight( summarize_from_disk(app, record, &runtimes) } -enum LocalStartIntent { +fn refresh_launch_persona( + app: &AppHandle, + record: &mut ManagedAgentRecord, +) -> Result<(), String> { + if let Some(id) = record.persona_id.clone() { + let personas = load_personas(app)?; + let persona = personas + .iter() + .find(|p| p.id == id) + .ok_or(crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR)?; + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = crate::util::now_iso(); + } + Ok(()) +} + +pub(crate) enum LocalStartIntent { Create, Explicit, Automatic, @@ -178,6 +231,30 @@ async fn start_local_agent_with_preflight( Option<&crate::managed_agents::runtime_configurations::RuntimeConfigurationRef>, >, ) -> Result { + start_local_agent_with_preflight_using(app, state, pubkey, intent, + expected_relay_url, expected_signer_pubkey, replay_floor_unix, requested, + |model, allow| async move { ensure_relay_mesh_for_record(app, model.as_deref(), allow).await }).await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn start_local_agent_with_preflight_using( + app: &AppHandle, + state: &AppState, + pubkey: &str, + intent: LocalStartIntent, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, + requested: Option< + Option<&crate::managed_agents::runtime_configurations::RuntimeConfigurationRef>, + >, + preflight: F, +) -> Result +where + R: tauri::Runtime, + F: FnOnce(Option, bool) -> Fut, + Fut: std::future::Future>, +{ let launch_owner = workspace_owner_hex(state)?; // Runtime keys preserve the workspace host authority. Bind that same // authority across the preflight await so the eventual spawn cannot move. @@ -197,73 +274,44 @@ async fn start_local_agent_with_preflight( } else { None }; - let record_snapshot = { - let _store_guard = state + let mut prepared = { + let _store = 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 selected = crate::managed_agents::runtime_configurations::selected_reference( - &record_snapshot, - Some(&launch_owner), - launch_relay.as_str(), - )?; - let configuration = requested.map(|r| r.cloned()).unwrap_or(selected); - let prepared = if requested.is_some() || configuration.is_some() { - Some( - crate::managed_agents::runtime_configurations::prepare_for_app( + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + refresh_launch_persona(app, record)?; + let plan = match requested { + Some(reference) => crate::managed_agents::runtime_configurations::prepare_for_app( app, - &record_snapshot, - configuration.as_ref(), + record, + reference, &launch_owner, launch_relay.as_str(), )?, - ) - } else { - None + None => crate::managed_agents::runtime_configurations::capture_for_app( + app, + record, + &launch_owner, + launch_relay.as_str(), + )?, + }; + save_managed_agents(app, &records)?; + plan }; - if let Some(plan) = &prepared { - crate::managed_agents::runtime_configurations::preflight_prepared( - app, - plan, - &launch_owner, - launch_relay.as_str(), - ) - .await?; - } else { - 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(), - matches!(intent, LocalStartIntent::Create), - ) - .await?; - } + let configuration = prepared.configuration(); + crate::managed_agents::runtime_configurations::preflight_with( + &mut prepared, + &launch_owner, + launch_relay.as_str(), + matches!(intent, LocalStartIntent::Create), + preflight, + ) + .await?; // The mesh preflight above is the suspension window Projects callbacks // capture their scope against: a community switch during that await @@ -308,34 +356,13 @@ async fn start_local_agent_with_preflight( configuration.as_ref(), )?; } - if let Some(plan) = &prepared { - plan.revalidate( - record, - &load_personas(app)?, - &crate::managed_agents::load_global_agent_config(app)?, - )?; - } - // 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().filter(|_| prepared.is_none()) { - 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(), - ); - } - } - } + prepared.check_continuation(record)?; + prepared.revalidate( + record, + &load_personas(app)?, + &crate::managed_agents::load_global_agent_config(app)?, + )?; + let personas = load_personas(app)?; crate::managed_agents::start_managed_agent_process_prepared( app, record, @@ -344,7 +371,7 @@ async fn start_local_agent_with_preflight( &workspace_relay_url, replay_floor_unix, resume.as_ref(), - prepared.as_ref(), + Some(&prepared), )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { diff --git a/desktop/src-tauri/src/commands/agents_pending.rs b/desktop/src-tauri/src/commands/agents_pending.rs index 0a7f91eb854..8c8683a3c4c 100644 --- a/desktop/src-tauri/src/commands/agents_pending.rs +++ b/desktop/src-tauri/src/commands/agents_pending.rs @@ -22,8 +22,8 @@ use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; /// only runtime fields produces an identical row and never re-enqueues a /// publish. Best-effort: a failure here is logged and swallowed so a retention /// hiccup never blocks the disk-authoritative write. -pub(crate) fn retain_managed_agent_pending( - app: &AppHandle, +pub(crate) fn retain_managed_agent_pending( + app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, ) { diff --git a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs index 14ca29e759d..3ac743de6ba 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -44,17 +44,9 @@ impl EffectiveAgentConfig { /// a blank effective model falls back to "auto", mirroring /// `apply_relay_mesh_env`'s own rule. pub fn relay_mesh_model_id(&self) -> Option { - if self.provider.value.as_deref().map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { - return None; - } - Some( - self.model - .value - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) - .to_string(), + super::resolved_relay_mesh_model_id( + self.provider.value.as_deref(), + self.model.value.as_deref(), ) } } diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 3858212bbba..f8aaa5b4161 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,5 +1,23 @@ pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; +/// Classify resolved configuration, never a next-launch selection. Used by +/// preflight/spawn and by recovery over the actual running spawn snapshot. +pub(crate) fn resolved_relay_mesh_model_id( + provider: Option<&str>, + model: Option<&str>, +) -> Option { + if provider.map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { + return None; + } + Some( + model + .map(str::trim) + .filter(|m| !m.is_empty()) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_owned(), + ) +} + pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; /// Stored value for "let the mesh decide", kept as the user-facing word. pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto"; diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs index 81a21e353fd..5fb970f147e 100644 --- a/desktop/src-tauri/src/managed_agents/remote_stop.rs +++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs @@ -162,6 +162,12 @@ pub(crate) fn check_launch( resume: Option<&ResumeTicket>, ) -> Result<(), String> { let state = app.state::(); + if state + .shutdown_started + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("desktop shutdown has started".into()); + } let current_owner = state.signing_keys()?.public_key().to_hex(); if owner != Some(current_owner.as_str()) { return Err("Desktop launch owner changed".into()); diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index b6a6e482cb8..841fc01a14c 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -99,6 +99,53 @@ pub async fn restore_managed_agents_on_launch( app: &tauri::AppHandle, shutdown_started: &AtomicBool, ) -> Result<(), String> { + restore_with( + app, + shutdown_started, + |model, _| async move { + #[cfg(feature = "mesh-llm")] + crate::commands::ensure_relay_mesh_for_record(app, model.as_deref(), false).await?; + #[cfg(not(feature = "mesh-llm"))] + let _ = model; + Ok(()) + }, + |tracked_pids| { + super::sweep_orphaned_agent_processes(app, tracked_pids); + super::sweep_system_agent_processes(&super::current_instance_id(app), tracked_pids); + super::reap_dead_instance_agents(&super::current_instance_id(app), tracked_pids); + super::sweep_untracked_bundle_harnesses(tracked_pids); + }, + |pubkey, data| { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Err(error) = + crate::commands::reconcile_agent_profile(&state, &app, &pubkey, &data).await + { + eprintln!( + "buzz-desktop: profile reconciliation failed for agent {pubkey}: {error}" + ); + } + }); + }, + ) + .await +} + +// Same restore phases with only external preflight, system sweeps and relay +// publication injectable. Tests run disk/admission/spawn/receipt code unchanged. +pub(crate) async fn restore_with( + app: &tauri::AppHandle, + shutdown_started: &AtomicBool, + preflight: F, + sweep: impl Fn(&[u32]), + reconcile: impl Fn(String, crate::commands::ProfileReconcileData), +) -> Result<(), String> +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ if shutdown_started.load(Ordering::SeqCst) { return Ok(()); } @@ -151,28 +198,7 @@ pub async fn restore_managed_agents_on_launch( }), ) .collect(); - super::sweep_orphaned_agent_processes(app, &tracked_pids); - - // System-wide sweep: enumerate all user processes and kill any known - // agent binaries not tracked by this session. Catches orphans whose - // PID files were already cleaned up (e.g. agent workers in their own - // process group whose parent harness exited). - super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids); - - // Dead-instance reaping: find agents belonging to Buzz instances - // whose desktop process is no longer running and reap them. - super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); - - // Exact-path sweep: kill any buzz-acp process whose executable path - // matches this bundle's harness binary but is not in the tracked set. - // Complements the env-var sweep above — catches orphans that predate - // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. - // - // TODO: the three sweeps above each walk the PID table independently. - // A future consolidation should collect a single shared process snapshot - // at the top of this block and thread it through all sweep functions, - // replacing the three separate kernel enumerations. - super::sweep_untracked_bundle_harnesses(&tracked_pids); + sweep(&tracked_pids); let candidates: Vec = records .iter() @@ -252,36 +278,31 @@ pub async fn restore_managed_agents_on_launch( // Capture the actual scoped selection before awaiting, never preflight Default // and recapture a different selected model at spawn. let launch_relay = crate::relay::relay_ws_url_with_override(&state); + // Capture the whole batch before any provider suspends. + let captured = agents_to_start + .into_iter() + .map(|record| { + let plan = super::runtime_configurations::capture_for_app( + app, + &record, + owner_hex.as_deref().unwrap_or(""), + &launch_relay, + ); + (record, plan) + }) + .collect::>(); let mut prepared_agents = Vec::new(); - for record in agents_to_start { - let preparation = super::runtime_configurations::prepare_selected( - app, - &record, - owner_hex.as_deref(), - &launch_relay, - ); + for (record, preparation) in captured { let result = async { - let plan = preparation?; - if let Some(plan) = &plan { - super::runtime_configurations::preflight_prepared( - app, - plan, - owner_hex.as_deref().ok_or("Desktop owner unavailable")?, - &launch_relay, - ) - .await?; - } else { - #[cfg(feature = "mesh-llm")] - { - let model = super::effective_config::resolve_effective_relay_mesh_model_id( - &record, - &load_personas(app)?, - &super::load_global_agent_config(app)?, - ); - crate::commands::ensure_relay_mesh_for_record(app, model.as_deref(), false) - .await?; - } - } + let mut plan = preparation?; + super::runtime_configurations::preflight_with( + &mut plan, + owner_hex.as_deref().ok_or("Desktop owner unavailable")?, + &launch_relay, + false, + &preflight, + ) + .await?; Ok::<_, String>(plan) } .await; @@ -358,18 +379,22 @@ pub async fn restore_managed_agents_on_launch( current, owner_hex_ref, &relay_url, - prepared - .as_ref() - .and_then(|plan| plan.configuration()) - .as_ref(), + prepared.configuration().as_ref(), + )?; + prepared.check_continuation(current)?; + prepared.require_preflight()?; + prepared.revalidate( + current, + &load_personas(app)?, + &super::load_global_agent_config(app)?, + )?; + super::remote_stop::check_launch( + app, + &key, + &relay_url, + owner_hex_ref, + None, )?; - if let Some(plan) = prepared { - plan.revalidate( - current, - &load_personas(app)?, - &super::load_global_agent_config(app)?, - )?; - } super::terminate_untracked_pair_runtime(app, &key)?; super::runtime::spawn_agent_child_with_broker( app, @@ -380,7 +405,7 @@ pub async fn restore_managed_agents_on_launch( None, None, None, - prepared.as_ref(), + Some(prepared), ) })(); match result { @@ -517,16 +542,7 @@ pub async fn restore_managed_agents_on_launch( // Spawn background tasks to ensure each restored agent's kind:0 profile is // published on the relay. Same pattern as the UI start path. for (pubkey, data) in reconcile_items { - let reconcile_app = app.clone(); - tauri::async_runtime::spawn(async move { - let state = reconcile_app.state::(); - if let Err(e) = - crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) - .await - { - eprintln!("buzz-desktop: profile reconciliation failed for agent {pubkey}: {e}"); - } - }); + reconcile(pubkey, data); } Ok(()) @@ -599,9 +615,8 @@ mod profile_reconcile_tests { } } -#[cfg(feature = "mesh-llm")] -fn persist_restore_error( - app: &tauri::AppHandle, +fn persist_restore_error( + app: &tauri::AppHandle, state: &AppState, pubkey: &str, error: String, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index c73bddedadd..9382e8f04b8 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -135,8 +135,8 @@ pub(crate) fn resolve_workspace_pair_key( ManagedAgentRuntimeKey::new(pubkey.to_string(), &effective_relay).ok() } -pub fn build_managed_agent_summary( - app: &AppHandle, +pub fn build_managed_agent_summary( + app: &AppHandle, record: &ManagedAgentRecord, runtimes: &HashMap, personas: &[crate::managed_agents::types::AgentDefinition], @@ -516,7 +516,6 @@ pub(crate) fn spawn_agent_child_with_broker( // command, so we recompute them from the effective value rather than the // frozen record snapshot. Mirrors the model resolution below. let personas = super::load_personas(app).unwrap_or_default(); - let teams = super::load_teams(app).unwrap_or_default(); // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) // and for the env-var merge at spawn time. let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); @@ -532,46 +531,19 @@ pub(crate) fn spawn_agent_child_with_broker( // inherits it — no caller can bypass this by reaching `spawn_agent_child` // directly. Checked before any side effect (log marker, log file, process // spawn) so a refused spawn leaves no trace. - let selected_ref = - super::runtime_configurations::selected_reference(record, owner_hex, relay_url)?; - let resolved; - let prepared = match prepared { - Some(plan) => { - plan.check_scope(owner_hex, relay_url)?; - plan.revalidate(record, &personas, &global)?; - Some(plan) - } - None if selected_ref.is_some() => { - resolved = super::runtime_configurations::prepare_for_app( - app, - record, - selected_ref.as_ref(), - owner_hex.ok_or("Desktop owner unavailable")?, - relay_url, - )?; - Some(&resolved) - } - None => None, - }; - let record = prepared.map(|plan| &plan.record).unwrap_or(record); - let effective_cfg = match prepared { - Some(plan) => plan.effective.clone(), - None => super::effective_config::resolve_effective_config(record, &personas, &global) - .require_resolved()?, - }; - let descriptor = match prepared { - Some(plan) => plan.descriptor.clone(), - None => super::resolve_effective_harness_descriptor(record, &personas, &global)?, - }; + let plan = prepared.ok_or("Captured preflighted runtime launch required")?; + plan.require_preflight()?; + plan.check_scope(owner_hex, relay_url)?; + plan.revalidate(record, &personas, &global)?; + let record = &plan.record; + let effective_cfg = plan.effective.clone(); + let descriptor = &plan.descriptor; let effective_command = &descriptor.command; let agent_args = &descriptor.args; - let app_inputs = prepared - .map(|plan| { - plan.app_inputs - .as_ref() - .ok_or("Launch plan has no app inputs") - }) - .transpose()?; + let (team_instructions, acp_session_policy) = plan + .app_inputs + .as_ref() + .ok_or("Launch plan has no app inputs")?; let required_mcp = if super::runtime_configurations::selected(record)?.is_some() { super::runtime_configurations::required_mcp_command(effective_command)? } else { @@ -700,11 +672,7 @@ pub(crate) fn spawn_agent_child_with_broker( } } } - let team_instructions = match app_inputs { - Some((instructions, _)) => instructions.clone(), - None => super::spawn_snapshot::effective_team_instructions(record, &teams), - }; - if let Some(instructions) = &team_instructions { + if let Some(instructions) = team_instructions { command.env("BUZZ_ACP_TEAM_INSTRUCTIONS", instructions); } else { command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS"); @@ -835,15 +803,9 @@ pub(crate) fn spawn_agent_child_with_broker( for (key, value) in &descriptor.env { command.env(key, value); } - // Prepared launches bind session partitioning before async preflight; Default - // retains the existing launch-time experiment policy. Stamp exactly what we apply. - let acp_session_policy = match app_inputs { - Some((_, policy)) => { - super::session_policy::apply_acp_session_policy_env(&mut command, *policy); - *policy - } - None => super::apply_app_acp_session_policy_env(app, &mut command), - }; + // Session partitioning is launch input; operational admission/logging policy + // remains live. Default and named both stamp exactly the captured policy. + super::session_policy::apply_acp_session_policy_env(&mut command, *acp_session_policy); crate::build_identity::apply_demo_config_home(&mut command)?; // Publish-first replay floor: written AFTER the `descriptor.env` loop, the @@ -887,14 +849,14 @@ pub(crate) fn spawn_agent_child_with_broker( let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs( super::spawn_snapshot::SpawnConfigInputs { record, - descriptor: &descriptor, + descriptor, relay_url: &effective_relay_url, team_instructions: team_instructions.as_deref(), system_prompt: effective_prompt.as_deref(), model: effective_model.as_deref(), provider: effective_provider.as_deref(), enforced_owner_only: super::owner_only_access_build(), - session_policy: acp_session_policy, + session_policy: *acp_session_policy, }, ); @@ -1027,25 +989,16 @@ pub(crate) fn start_managed_agent_process_prepared( prepared: Option<&super::runtime_configurations::PreparedLaunch>, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; - let resolved = if prepared.is_none() { - super::runtime_configurations::prepare_selected( - app, - record, - owner_hex, - workspace_relay.as_str(), - )? - } else { - None - }; - let prepared = prepared.or(resolved.as_ref()); - if let Some(plan) = prepared { - plan.check_scope(owner_hex, workspace_relay.as_str())?; - plan.revalidate( - record, - &super::load_personas(app)?, - &super::load_global_agent_config(app)?, - )?; - } + super::with_pair_runtime_receipt_authority(app, &key, || Ok(()))?; + let plan = prepared.ok_or("Captured preflighted runtime launch required")?; + plan.require_preflight()?; + plan.check_scope(owner_hex, workspace_relay.as_str())?; + plan.revalidate( + record, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + )?; + super::remote_stop::check_launch(app, &key, workspace_relay.as_str(), owner_hex, resume)?; if let Some(runtime) = runtimes.get_mut(&key) { if runtime .child @@ -1053,17 +1006,7 @@ pub(crate) fn start_managed_agent_process_prepared( .map_err(|error| format!("failed to inspect running process: {error}"))? .is_none() { - let requested = match prepared { - Some(plan) => { - plan.check_scope(owner_hex, workspace_relay.as_str())?; - plan.configuration() - } - None => super::runtime_configurations::selected_reference( - record, - owner_hex, - workspace_relay.as_str(), - )?, - }; + let requested = plan.configuration(); if runtime.spawn_config.runtime_configuration != requested { return Err("A different configuration is running; Stop before Start".into()); } diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 2247209cf76..2ba8027ebf0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -224,16 +224,8 @@ pub async fn list_managed_agent_runtimes( .map_err(|e| format!("spawn_blocking failed: {e}"))? } -pub(crate) fn start_managed_agent_runtime_pair_lazy( - pubkey: String, - relay_url: String, - app: AppHandle, -) -> Result { - start_pair(pubkey, relay_url, true, None, false, app) -} - #[tauri::command] -pub fn start_managed_agent_runtime( +pub async fn start_managed_agent_runtime( pubkey: String, relay_url: String, explicit_start: Option, @@ -242,70 +234,169 @@ pub fn start_managed_agent_runtime( start_pair( pubkey, relay_url, - true, None, explicit_start.unwrap_or(false), + false, app, ) + .await } -fn start_pair( +async fn start_pair( pubkey: String, relay_url: String, - lazy: bool, - expected_updated_at: Option<&str>, + expected_record: Option<&super::ManagedAgentRecord>, explicit_start: bool, + restart: bool, app: AppHandle, ) -> Result { - let state = app.state::(); - let _transition = state - .managed_agent_runtime_transition - .lock() - .map_err(|e| e.to_string())?; - start_pair_locked( + start_pair_with_preflight( pubkey, relay_url, - lazy, - expected_updated_at, + expected_record, explicit_start, - None, + restart, app.clone(), + |model, allow| async move { + #[cfg(feature = "mesh-llm")] + crate::commands::ensure_relay_mesh_for_record(&app, model.as_deref(), allow).await?; + #[cfg(not(feature = "mesh-llm"))] + let _ = (model, allow); + Ok(()) + }, ) + .await } -// Caller owns the transition lock across admission, effect, receipt and result. -pub(crate) fn start_pair_locked( +/// Direct Start, Restart and reconcile share the same capture/preflight/admit +/// route. Only provider I/O is replaceable in tests; child/receipt paths are real. +pub(crate) async fn start_pair_with_preflight( pubkey: String, relay_url: String, - lazy: bool, - expected_updated_at: Option<&str>, + expected_record: Option<&super::ManagedAgentRecord>, explicit_start: bool, - broker: Option<&super::broker_launch::BrokerSession>, - app: AppHandle, -) -> Result { - start_pair_prepared_locked( - pubkey, - relay_url, - lazy, - expected_updated_at, - explicit_start, - broker, - None, - app, + restart: bool, + app: AppHandle, + preflight: F, +) -> Result +where + R: tauri::Runtime, + F: FnOnce(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + let state = app.state::(); + let key = ManagedAgentRuntimeKey::new(pubkey.clone(), &relay_url)?; + let owner = state.signing_keys()?.public_key().to_hex(); + let (mut plan, resume, generation) = { + 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 records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("Agent not found")?; + let plan = + super::runtime_configurations::capture_for_app(&app, record, &owner, &key.relay_url)?; + if let Some(probed) = expected_record { + // Relay authorization used these identity/access inputs. Ignore + // lifecycle-only timestamp churn from another community's launch, + // not record/definition/config edits or a Stop during that probe. + plan.revalidate( + probed, + &load_personas(&app)?, + &load_global_agent_config(&app)?, + )?; + if record.last_stopped_at.is_some() && record.last_stopped_at != probed.last_stopped_at + { + return Err("Stop interrupted runtime reconciliation".into()); + } + } + let resume = if explicit_start && !restart { + Some(super::remote_stop::capture_resume( + &app, + &key, + &key.relay_url, + &owner, + )?) + } else { + None + }; + let generation = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())? + .get(&key) + .map(|r| r.start_nonce.clone()); + (plan, resume, generation) + }; + super::runtime_configurations::preflight_with( + &mut plan, + &owner, + &key.relay_url, + false, + preflight, ) + .await?; + let app = app.clone(); + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + start_pair_captured_locked( + pubkey, + key.relay_url, + true, + None, + resume.as_ref(), + None, + &plan, + true, + restart, + Some(&generation), + app.clone(), + ) + }) + .await + .map_err(|e| format!("runtime admission task failed: {e}"))? +} + +/// The keyless lifecycle adapter remains provisioning-unavailable. A synchronous +/// caller cannot manufacture async launch authority when that adapter is added; +/// it must carry a preflighted plan into start_pair_captured_locked instead. +pub(crate) fn start_pair_locked( + _pubkey: String, + _relay_url: String, + _lazy: bool, + _expected_updated_at: Option<&str>, + _explicit_start: bool, + _broker: Option<&super::broker_launch::BrokerSession>, + _app: AppHandle, +) -> Result { + Err("Captured preflighted runtime launch required".into()) } -/// Caller owns the transition lock and admission; no selection is persisted here. +/// Captured automatic starts additionally fence the durable next-launch selection. #[allow(clippy::too_many_arguments)] -pub(crate) fn start_pair_prepared_locked( +pub(crate) fn start_pair_captured_locked( pubkey: String, relay_url: String, lazy: bool, expected_updated_at: Option<&str>, - explicit_start: bool, + resume: Option<&super::remote_stop::ResumeTicket>, broker: Option<&super::broker_launch::BrokerSession>, - prepared: Option<&super::runtime_configurations::PreparedLaunch>, - app: AppHandle, + plan: &super::runtime_configurations::PreparedLaunch, + check_selection: bool, + restart: bool, + expected_generation: Option<&Option>, + app: AppHandle, ) -> Result { let state = app.state::(); if state.shutdown_started.load(Ordering::Acquire) { @@ -325,43 +416,57 @@ pub(crate) fn start_pair_prepared_locked( } let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; let owner = state.signing_keys()?.public_key().to_hex(); - let resolved = if prepared.is_none() { - super::runtime_configurations::prepare_selected(&app, record, Some(&owner), &relay_url)? - } else { - None - }; - let prepared = prepared.or(resolved.as_ref()); - if let Some(plan) = prepared { - plan.check_scope(Some(&owner), &relay_url)?; - plan.revalidate( + plan.require_preflight()?; + plan.check_scope(Some(&owner), &key.relay_url)?; + if expected_generation.is_some() { + plan.check_continuation(record)?; + } + if check_selection { + super::runtime_configurations::check_selection( record, - &super::load_personas(&app)?, - &super::load_global_agent_config(&app)?, + Some(&owner), + &key.relay_url, + plan.configuration().as_ref(), )?; } + plan.revalidate( + record, + &load_personas(&app)?, + &load_global_agent_config(&app)?, + )?; + super::remote_stop::check_launch(&app, &key, &key.relay_url, Some(&owner), resume)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; + if expected_generation.is_some_and(|expected| { + &runtimes + .get(&key) + .map(|runtime| runtime.start_nonce.clone()) + != expected + }) { + return Err("Runtime generation changed during preflight; retry Start".into()); + } + if restart { + // Validate target, Stop/placement and generation BEFORE touching the old + // child. Store lock stays held through teardown, spawn and receipt. + super::with_pair_runtime_receipt_authority(&app, &key, || { + reject_unscoped_live_child( + record.runtime_pid.filter(|pid| process_is_running(*pid)), + runtimes.values().map(|runtime| runtime.child.id()), + )?; + if runtimes.contains_key(&key) { + super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?; + } + Ok(()) + })?; + } if runtimes .get_mut(&key) .is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none()) { let status = status_for(&app, record, &key, runtimes.get(&key), None); - let requested = match prepared { - Some(plan) => { - plan.check_scope( - Some(&state.signing_keys()?.public_key().to_hex()), - &relay_url, - )?; - plan.configuration() - } - None => super::runtime_configurations::selected_reference( - record, - Some(&state.signing_keys()?.public_key().to_hex()), - &relay_url, - )?, - }; + let requested = plan.configuration(); if status.running_configuration != requested { return Err("A different configuration is running; Stop before Start".into()); } @@ -370,52 +475,63 @@ pub(crate) fn start_pair_prepared_locked( runtimes.remove(&key); terminate_untracked_pair_runtime(&app, &key)?; - let owner = state - .keys - .lock() - .ok() - .map(|keys| keys.public_key().to_hex()); - let resume = if explicit_start { - Some(super::remote_stop::capture_resume( + if restart { + record.last_stopped_at = Some(crate::util::now_iso()); + state.clear_agent_session_cache(&key); + } + let process_result = (|| { + let mut process = super::spawn_agent_child_with_broker( &app, - &key, + record, &relay_url, - owner.as_deref().ok_or("Desktop owner unavailable")?, - )?) - } else { - None + lazy, + Some(&owner), + None, + resume, + broker, + Some(plan), + )?; + let mut receipt = ManagedAgentRuntimeReceipt::new( + key.clone(), + process.child.id(), + current_instance_id(&app), + crate::util::now_iso(), + ); + receipt.runtime_configuration = process.spawn_config.runtime_configuration.clone(); + if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { + let _ = terminate_process(process.child.id()); + let _ = process.child.wait(); + return Err(error); + } + Ok(process) + })(); + let process = match process_result { + Ok(process) => process, + Err(error) if restart => { + // Teardown succeeded but launch failed. Return an honest existing + // Failed status (no PID) so the UI can retire only the old turns. + // Preflight/admission/Stop failures above still return Err and leave + // the old child's badge alone. + record.last_error = Some(error.clone()); + record.updated_at = crate::util::now_iso(); + let mut status = status_for(&app, record, &key, None, None); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + drop(runtimes); + save_managed_agents(&app, &records)?; + emit_status(&app, &status); + return Ok(status); + } + Err(error) => return Err(error), }; - let mut process = super::spawn_agent_child_with_broker( - &app, - record, - &relay_url, - lazy, - owner.as_deref(), - None, - resume.as_ref(), - broker, - prepared, - )?; let now = crate::util::now_iso(); - let mut receipt = ManagedAgentRuntimeReceipt::new( - key.clone(), - process.child.id(), - current_instance_id(&app), - now.clone(), - ); - receipt.runtime_configuration = process.spawn_config.runtime_configuration.clone(); - if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { - let _ = terminate_process(process.child.id()); - let _ = process.child.wait(); - return Err(error); - } record.runtime_pid = None; record.updated_at = now.clone(); record.last_started_at = Some(now); record.last_stopped_at = None; record.last_error = None; runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); - super::remote_stop::finish_resume(&app, &key, &relay_url, owner.as_deref(), resume.as_ref())?; + super::remote_stop::finish_resume(&app, &key, &relay_url, Some(&owner), resume)?; let status = status_for(&app, record, &key, runtimes.get(&key), None); drop(runtimes); save_managed_agents(&app, &records)?; @@ -502,13 +618,12 @@ fn reject_unscoped_live_child( } #[tauri::command] -pub fn restart_managed_agent_runtime( +pub async fn restart_managed_agent_runtime( pubkey: String, relay_url: String, app: AppHandle, ) -> Result { - stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?; - start_pair(pubkey, relay_url, true, None, false, app) + start_pair(pubkey, relay_url, None, false, true, app).await } /// Probe whether this agent can operate on `requested_relay_url`. @@ -617,82 +732,75 @@ pub async fn reconcile_managed_agent_runtimes( .collect() .await; - // start_pair does blocking work (std mutexes, process spawn, receipt - // writes, and up-to-2s exit polling in terminate_untracked_pair_runtime), - // so run the post-probe start loop off the async workers, matching the - // restart flows. - tokio::task::spawn_blocking(move || { - let personas = load_personas(&app).unwrap_or_default(); - let global = load_global_agent_config(&app).unwrap_or_default(); - let mut rows = Vec::new(); - for probe in probes { - match probe { - Ok((record, key, requested)) => { - match start_pair( - record.pubkey.clone(), - requested.clone(), - true, - Some(&record.updated_at), - false, - app.clone(), - ) { - Ok(mut status) => { - status.requested_relay_url = Some(requested); - rows.push(status); - } - Err(error) => { - let mut status = status_for_with( - &app, - &record, - &key, - None, - Some(requested), - StatusInputs { - personas: &personas, - global: &global, - }, - ); - status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; - status.error = Some(error); - rows.push(status); - } + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); + let mut rows = Vec::new(); + for probe in probes { + match probe { + Ok((record, key, requested)) => { + match start_pair( + record.pubkey.clone(), + requested.clone(), + Some(&record), + false, + false, + app.clone(), + ) + .await + { + Ok(mut status) => { + status.requested_relay_url = Some(requested); + rows.push(status); + } + Err(error) => { + let mut status = status_for_with( + &app, + &record, + &key, + None, + Some(requested), + StatusInputs { + personas: &personas, + global: &global, + }, + ); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + rows.push(status); } - } - Err((record, requested, error)) => { - // Per-community degradation: a relay URL that cannot even - // form a pair key gets a Failed row (with the raw - // requested URL) like any other probe failure, instead of - // aborting every other community's row. - let status = - match ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested) { - Ok(key) => { - let mut status = status_for_with( - &app, - &record, - &key, - None, - Some(requested), - StatusInputs { - personas: &personas, - global: &global, - }, - ); - status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; - status.error = Some(error); - status - } - Err(_) => unkeyable_failed_status( - &record, requested, error, &personas, &global, - ), - }; - rows.push(status); } } + Err((record, requested, error)) => { + // Per-community degradation: a relay URL that cannot even + // form a pair key gets a Failed row (with the raw + // requested URL) like any other probe failure, instead of + // aborting every other community's row. + let status = match ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested) { + Ok(key) => { + let mut status = status_for_with( + &app, + &record, + &key, + None, + Some(requested), + StatusInputs { + personas: &personas, + global: &global, + }, + ); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + status + } + Err(_) => { + unkeyable_failed_status(&record, requested, error, &personas, &global) + } + }; + rows.push(status); + } } - rows - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}")) + } + Ok(rows) } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs index e30dff65fed..8a529f033dc 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs @@ -41,6 +41,8 @@ pub(crate) struct PreparedLaunch { pub(super) record: ManagedAgentRecord, pub(super) descriptor: EffectiveHarnessDescriptor, pub(super) effective: super::effective_config::EffectiveAgentConfig, + legacy_default: bool, + preflight_complete: bool, host: String, scope: (String, String), // Catalog-only preparation has no app context and cannot be executed. @@ -58,6 +60,22 @@ impl PreparedLaunch { Ok(()) } + /// Preparation is not launch authority. Only the successful ordinary async + /// provider boundary can authorize these exact inputs for shared spawn. + pub(crate) fn require_preflight(&self) -> Result<(), String> { + if !self.preflight_complete { + return Err("Captured runtime launch has not completed provider preflight".into()); + } + Ok(()) + } + + pub(crate) fn check_continuation(&self, record: &ManagedAgentRecord) -> Result<(), String> { + if self.record.last_stopped_at != record.last_stopped_at { + return Err("Stop interrupted runtime preflight; retry Start".into()); + } + Ok(()) + } + pub(crate) fn record(&self) -> &ManagedAgentRecord { &self.record } @@ -89,15 +107,19 @@ impl PreparedLaunch { if comparable != self.record { return Err("Agent changed during runtime preflight; retry Start".into()); } - let current = prepare( - record, - self.configuration().as_ref(), - personas, - global, - &self.host, - &self.scope.0, - &self.scope.1, - )?; + let current = if self.legacy_default { + prepare_default(record, personas, global, &self.scope.0, &self.scope.1)? + } else { + prepare( + record, + self.configuration().as_ref(), + personas, + global, + &self.host, + &self.scope.0, + &self.scope.1, + )? + }; if selected(¤t.record)? != selected(&self.record)? || current.descriptor != self.descriptor || current.effective != self.effective @@ -136,12 +158,64 @@ pub(crate) fn prepare( record: projected, descriptor, effective, + legacy_default: false, + preflight_complete: false, host: host.into(), scope: (owner.into(), community.into()), app_inputs: None, }) } +// Default preserves legacy readiness/setup-listener and unattested-record behavior, +// but its effective inputs must still be immutable across async preflight. +fn prepare_default( + record: &ManagedAgentRecord, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, + owner: &str, + community: &str, +) -> Result { + let mut record = record.clone(); + record.runtime_configurations.launch = None; + Ok(PreparedLaunch { + descriptor: super::resolve_effective_harness_descriptor(&record, personas, global)?, + effective: super::effective_config::resolve_effective_config(&record, personas, global) + .require_resolved()?, + record, + legacy_default: true, + preflight_complete: false, + host: String::new(), + scope: (owner.into(), community.into()), + app_inputs: None, + }) +} + +/// Capture ordinary next-launch authority, including legacy Default. Never read +/// selection again to choose a launch after preflight; only check it at admission. +pub(crate) fn capture_for_app( + app: &tauri::AppHandle, + record: &ManagedAgentRecord, + owner: &str, + community: &str, +) -> Result { + use tauri::Manager; + if let Some(reference) = selected_reference(record, Some(owner), community)? { + return prepare_for_app(app, record, Some(&reference), owner, community); + } + let mut plan = prepare_default( + record, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + owner, + community, + )?; + plan.app_inputs = Some(( + super::spawn_snapshot::effective_team_instructions(record, &super::load_teams(app)?), + super::acp_session_policy(app.state::().inner()), + )); + Ok(plan) +} + /// Absent selection is the legacy Default configuration, with unchanged inheritance. #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -448,26 +522,6 @@ pub(crate) fn check_selection( Ok(()) } -/// Validate or resolve before any existing pair is reaped. The shared spawn checks again. -pub(crate) fn prepare_selected( - app: &tauri::AppHandle, - record: &ManagedAgentRecord, - owner: Option<&str>, - community: &str, -) -> Result, String> { - selected_reference(record, owner, community)? - .map(|reference| { - prepare_for_app( - app, - record, - Some(&reference), - owner.ok_or("Desktop owner unavailable")?, - community, - ) - }) - .transpose() -} - /// Scoped safe catalog for lifecycle consumers; never exposes the global configuration store. pub(crate) fn catalog_for_app( app: &tauri::AppHandle, @@ -487,24 +541,23 @@ pub(crate) fn catalog_for_app( )) } -/// Ordinary async mesh readiness, outside the transition lock. Caller revalidates -/// owner/community after this await and the immutable plan under its admission lock. -pub(crate) async fn preflight_prepared( - app: &tauri::AppHandle, - plan: &PreparedLaunch, +/// Run the ordinary provider preflight against this captured plan. The callback +/// is the existing async mesh boundary (fixture-controlled in orchestration tests). +pub(crate) async fn preflight_with( + plan: &mut PreparedLaunch, owner: &str, community: &str, -) -> Result<(), String> { + allow_create: bool, + preflight: F, +) -> Result<(), String> +where + F: FnOnce(Option, bool) -> Fut, + Fut: std::future::Future>, +{ plan.check_scope(Some(owner), community)?; - #[cfg(feature = "mesh-llm")] - crate::commands::ensure_relay_mesh_for_record( - app, - plan.effective.relay_mesh_model_id().as_deref(), - false, - ) - .await?; - #[cfg(not(feature = "mesh-llm"))] - let _ = app; + plan.preflight_complete = false; + preflight(plan.effective.relay_mesh_model_id(), allow_create).await?; + plan.preflight_complete = true; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs new file mode 100644 index 00000000000..65b5cc8d8b2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs @@ -0,0 +1,730 @@ +//! Production orchestration with only provider I/O, process sweeps and relay +//! publication replaced. No external providers or system agent enumeration. +use super::*; +use std::{cell::RefCell, os::unix::fs::PermissionsExt, sync::atomic::AtomicBool}; +use tauri::Manager; + +const ONE: &str = "wss://launch-one.example"; +const TWO: &str = "wss://launch-two.example"; + +struct Fixture { + app: tauri::App, + record: ManagedAgentRecord, + owner: String, + env: Vec<(&'static str, Option)>, + temp: tempfile::TempDir, +} +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let env = ["HOME", "XDG_DATA_HOME", "PATH"] + .map(|key| (key, std::env::var_os(key))) + .to_vec(); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + std::env::set_var("PATH", format!("{}:/usr/bin:/bin", temp.path().display())); + agents::clear_resolve_cache(); + for name in ["buzz-agent", "buzz-dev-mcp", "buzz-acp"] { + let path = temp.path().join(name); + let script = if name == "buzz-acp" { + format!("#!/bin/sh\nprintf '%s|%s|%s\\n' \"$BUZZ_ACP_MODEL\" \"$BUZZ_ACP_REQUIRED_MODEL\" \"$BUZZ_ACP_LAZY_POOL\" >> '{}'\n", temp.path().join("launches").display()) + } else { + "#!/bin/sh\nexit 0\n".into() + }; + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let state = app.state::(); + *state.relay_url_override.lock().unwrap() = Some(ONE.into()); + let mut record = record(); + let owner = attest(&mut record, &state.signing_keys().unwrap()); + record.acp_command = temp.path().join("buzz-acp").display().to_string(); + record.runtime = Some("buzz-agent".into()); + record.agent_command = "buzz-agent".into(); + record.model = Some("default-model".into()); + record.provider = Some("openai".into()); + record.start_on_app_launch = true; + record + .env_vars + .insert("OPENAI_COMPAT_API_KEY".into(), "fixture-only".into()); + Self { + app, + record, + owner, + env, + temp, + } + } + fn named(&mut self, relay: &str, provider: &str, model: &str) -> RuntimeConfiguration { + let host = local_host(self.app.handle(), &self.owner, relay).unwrap(); + let mut entry = config(&host); + entry.provider = Some(provider.into()); + entry.model = model.into(); + save(&mut self.record, &self.owner, relay, entry) + } + fn persist(&self) { + agents::save_managed_agents(self.app.handle(), &[self.record.clone()]).unwrap(); + } + fn finish_children(&self) -> Vec<(String, Option)> { + let state = self.app.state::(); + let mut runtimes = state.managed_agent_processes.lock().unwrap(); + runtimes + .iter_mut() + .map(|(key, runtime)| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + if let Some(status) = runtime.child.try_wait().unwrap() { + assert!(status.success()); + break; + } + assert!( + std::time::Instant::now() < deadline, + "fixture child timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + ( + key.relay_url.clone(), + runtime.spawn_config.runtime_configuration.clone(), + ) + }) + .collect() + } + fn no_launch(&self) { + assert!(self + .app + .state::() + .managed_agent_processes + .lock() + .unwrap() + .is_empty()); + assert!(!self.temp.path().join("launches").exists()); + assert!(agents::read_all_agent_runtime_receipts(self.app.handle()).is_empty()); + } +} +impl Drop for Fixture { + fn drop(&mut self) { + // Only owned fixture children; never call system-wide cleanup helpers. + for (_, mut runtime) in self + .app + .state::() + .managed_agent_processes + .lock() + .unwrap() + .drain() + { + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } + for (key, value) in self.env.drain(..) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + agents::clear_resolve_cache(); + } +} + +#[tokio::test] +async fn ordinary_default_start_cannot_launch_new_selection_after_preflight() { + let _guard = agents::lock_path_mutex(); + let mut fixture = Fixture::new(); + fixture.persist(); + let app = fixture.app.handle().clone(); + let state = app.state::(); + let pubkey = fixture.record.pubkey.clone(); + let (resume, wait) = tokio::sync::oneshot::channel(); + let future = crate::commands::start_local_agent_with_preflight_using( + &app, + &state, + &pubkey, + crate::commands::LocalStartIntent::Explicit, + None, + None, + None, + None, + |model, _| async move { + assert_eq!(model, None); // Default is openai, not the later mesh pick. + wait.await.unwrap(); + Ok(()) + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + fixture.named(ONE, "relay-mesh", "never-preflighted"); + fixture.persist(); + resume.send(()).unwrap(); + assert!(future + .await + .unwrap_err() + .contains("Selected configuration changed")); + fixture.no_launch(); +} + +#[tokio::test] +async fn restore_preflights_selected_provider_in_both_directions() { + let _guard = agents::lock_path_mutex(); + for (default_provider, named_provider, expected) in [ + ("relay-mesh", "openai", None), + ("openai", "relay-mesh", Some("selected-model")), + ] { + let mut fixture = Fixture::new(); + fixture.record.provider = Some(default_provider.into()); + let named = fixture.named(ONE, named_provider, "selected-model"); + fixture.persist(); + let calls = RefCell::new(Vec::new()); + let sweeps = RefCell::new(0); + let published = RefCell::new(Vec::new()); + agents::restore_with( + fixture.app.handle(), + &AtomicBool::new(false), + |model, _| { + calls.borrow_mut().push(model.clone()); + async move { + assert_eq!(model.as_deref(), expected); + Ok(()) + } + }, + |pids| { + assert!(pids.is_empty()); + *sweeps.borrow_mut() += 1; + }, + |key, _| published.borrow_mut().push(key), + ) + .await + .unwrap(); + assert_eq!(*calls.borrow(), vec![expected.map(str::to_owned)]); + assert_eq!(*sweeps.borrow(), 1); + assert_eq!(*published.borrow(), vec![fixture.record.pubkey.clone()]); + assert_eq!( + fixture.finish_children(), + vec![(ONE.into(), Some(named.reference()))] + ); + assert!( + std::fs::read_to_string(fixture.temp.path().join("launches")) + .unwrap() + .starts_with("selected-model|selected-model|") + ); + } +} + +#[tokio::test] +async fn restore_refuses_provider_preflight_failure_before_spawn() { + let _guard = agents::lock_path_mutex(); + let mut fixture = Fixture::new(); + fixture.named(ONE, "relay-mesh", "offline-model"); + fixture.persist(); + agents::restore_with( + fixture.app.handle(), + &AtomicBool::new(false), + |model, _| async move { + assert_eq!(model.as_deref(), Some("offline-model")); + Err("fixture peer offline".into()) + }, + |_| {}, + |_, _| panic!("failed restore must not publish"), + ) + .await + .unwrap(); + fixture.no_launch(); + assert_eq!( + agents::load_managed_agents(fixture.app.handle()).unwrap()[0] + .last_error + .as_deref(), + Some("fixture peer offline") + ); +} + +#[tokio::test] +async fn bulk_restart_preflights_every_captured_community_and_revalidates_before_spawn() { + let _guard = agents::lock_path_mutex(); + // The unchanged case proves a real lazy pair spawn and receipts. The other + // cases edit while the first provider awaits, after BOTH plans were captured. + for mutation in ["unchanged", "revision", "workspace", "selection"] { + let mut fixture = Fixture::new(); + let first = fixture.named(ONE, "relay-mesh", "mesh-one"); + let second = fixture.named(TWO, "relay-mesh", "mesh-two"); + let workspace = fixture.temp.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let mut entry = second.clone(); + entry.workspace = Some(workspace.display().to_string()); + let second = save(&mut fixture.record, &fixture.owner, TWO, entry); + fixture.record.last_stopped_at = Some("prior security restart Stop".into()); + fixture.persist(); + let calls = RefCell::new(Vec::new()); + let (resume, wait) = tokio::sync::oneshot::channel(); + let wait = RefCell::new(Some(wait)); + let app = fixture.app.handle().clone(); + let state = app.state::(); + let pubkey = fixture.record.pubkey.clone(); + let relays = vec![ONE.into(), TWO.into()]; + let future = crate::commands::start_local_agent_pairs_with_preflight_using( + &app, + &state, + &pubkey, + &relays, + |model, _| { + calls.borrow_mut().push(model); + let wait = wait.borrow_mut().take(); + async move { + if let Some(wait) = wait { + wait.await.unwrap(); + } + Ok(()) + } + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + match mutation { + "revision" => { + let mut edit = second.clone(); + edit.model = "unpreflighted-edit".into(); + save(&mut fixture.record, &fixture.owner, TWO, edit); + fixture.persist(); + } + "workspace" => std::fs::remove_dir(&workspace).unwrap(), + "selection" => { + fixture + .record + .runtime_configurations + .replace( + &fixture.owner, + TWO, + &second.host, + RuntimeConfigurations { + selected: None, + entries: vec![second.clone()], + }, + ) + .unwrap(); + fixture.persist(); + } + _ => {} + } + resume.send(()).unwrap(); + let result = future.await; + assert_eq!( + *calls.borrow(), + vec![Some("mesh-one".into()), Some("mesh-two".into())] + ); + let mut launches = fixture.finish_children(); + launches.sort_by(|a, b| a.0.cmp(&b.0)); + if mutation == "unchanged" { + result.unwrap(); + assert_eq!( + launches, + vec![ + (ONE.into(), Some(first.reference())), + (TWO.into(), Some(second.reference())) + ] + ); + } else { + assert!(result.is_err()); + assert_eq!(launches, vec![(ONE.into(), Some(first.reference()))]); + } + let output = std::fs::read_to_string(fixture.temp.path().join("launches")).unwrap(); + assert!(output.lines().all(|line| line.ends_with("|true"))); + assert!(!output.contains("unpreflighted-edit")); + assert_eq!( + agents::read_all_agent_runtime_receipts(fixture.app.handle()).len(), + launches.len() + ); + } +} + +#[tokio::test] +async fn restore_selection_fence_survives_suspension_in_both_directions() { + let _guard = agents::lock_path_mutex(); + for starts_named in [false, true] { + let mut fixture = Fixture::new(); + let named = fixture.named(ONE, "relay-mesh", "named-model"); + if !starts_named { + fixture + .record + .runtime_configurations + .replace( + &fixture.owner, + ONE, + &named.host, + RuntimeConfigurations { + selected: None, + entries: vec![named.clone()], + }, + ) + .unwrap(); + } + fixture.persist(); + let app = fixture.app.handle().clone(); + let shutdown = AtomicBool::new(false); + let (resume, wait) = tokio::sync::oneshot::channel(); + let wait = RefCell::new(Some(wait)); + let future = agents::restore_with( + &app, + &shutdown, + |model, _| { + assert_eq!(model.as_deref(), starts_named.then_some("named-model")); + let wait = wait.borrow_mut().take().unwrap(); + async move { + wait.await.unwrap(); + Ok(()) + } + }, + |_| {}, + |_, _| panic!("stale restore must not publish"), + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + fixture + .record + .runtime_configurations + .replace( + &fixture.owner, + ONE, + &named.host, + RuntimeConfigurations { + selected: (!starts_named).then(|| named.id.clone()), + entries: vec![named.clone()], + }, + ) + .unwrap(); + fixture.persist(); + resume.send(()).unwrap(); + future.await.unwrap(); + fixture.no_launch(); + assert!(agents::load_managed_agents(&app).unwrap()[0] + .last_error + .as_deref() + .unwrap() + .contains("Selected configuration changed")); + } +} + +#[tokio::test] +async fn ordinary_default_revalidates_effective_inputs_not_only_selection() { + let _guard = agents::lock_path_mutex(); + let mut fixture = Fixture::new(); + fixture.persist(); + let app = fixture.app.handle().clone(); + let state = app.state::(); + let pubkey = fixture.record.pubkey.clone(); + let (resume, wait) = tokio::sync::oneshot::channel(); + let future = crate::commands::start_local_agent_with_preflight_using( + &app, + &state, + &pubkey, + crate::commands::LocalStartIntent::Automatic, + None, + None, + None, + None, + |model, _| async move { + assert_eq!(model, None); + wait.await.unwrap(); + Ok(()) + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + fixture.record.provider = Some("relay-mesh".into()); + fixture.record.model = Some("unpreflighted-default-edit".into()); + fixture.persist(); + resume.send(()).unwrap(); + assert!(future + .await + .unwrap_err() + .contains("Agent changed during runtime preflight")); + fixture.no_launch(); +} + +impl Fixture { + fn hold_children(&self) { + let path = self.temp.path().join("buzz-acp"); + let mut script = std::fs::read_to_string(&path).unwrap(); + // exec means fixture cleanup owns the only remaining PID; no orphan sleep. + script.push_str("exec /bin/sleep 30\n"); + std::fs::write(path, script).unwrap(); + } + fn running(&self, relay: &str) -> (u32, String, Option) { + let state = self.app.state::(); + let mut runtimes = state.managed_agent_processes.lock().unwrap(); + let key = agents::ManagedAgentRuntimeKey::new(&self.record.pubkey, relay).unwrap(); + let runtime = runtimes.get_mut(&key).unwrap(); + assert!(runtime.child.try_wait().unwrap().is_none()); + ( + runtime.child.id(), + runtime.start_nonce.clone(), + runtime.spawn_config.runtime_configuration.clone(), + ) + } +} + +#[tokio::test] +async fn direct_restart_preflights_target_and_preserves_old_child_on_refusal() { + let _guard = agents::lock_path_mutex(); + for relay in [ONE, TWO] { + for mutation in [ + "unchanged", + "revision", + "workspace", + "offline", + "generation", + ] { + let mut fixture = Fixture::new(); + fixture.hold_children(); + let old = fixture.named(relay, "openai", "old-model"); + fixture.persist(); + agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + None, + true, + false, + fixture.app.handle().clone(), + |model, _| async move { + assert_eq!(model, None); + Ok(()) + }, + ) + .await + .unwrap(); + let old_runtime = fixture.running(relay); + assert_eq!(old_runtime.2, Some(old.reference())); + let workspace = fixture.temp.path().join("target-workspace"); + std::fs::create_dir(&workspace).unwrap(); + let mut target = fixture.named(relay, "relay-mesh", "target-model"); + target.workspace = Some(workspace.display().to_string()); + let target = save(&mut fixture.record, &fixture.owner, relay, target); + fixture.persist(); + let (resume, wait) = tokio::sync::oneshot::channel(); + let future = agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + None, + false, + true, + fixture.app.handle().clone(), + |model, _| async move { + assert_eq!(model.as_deref(), Some("target-model")); + wait.await.unwrap(); + if mutation == "offline" { + Err("fixture provider unavailable".into()) + } else { + Ok(()) + } + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + assert_eq!(fixture.running(relay), old_runtime); + let mut replacement = None; + match mutation { + "revision" => { + let mut edited = target.clone(); + edited.model = "not-preflighted".into(); + save(&mut fixture.record, &fixture.owner, relay, edited); + fixture.persist(); + } + "workspace" => std::fs::remove_dir(&workspace).unwrap(), + "generation" => { + agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + None, + false, + true, + fixture.app.handle().clone(), + |_, _| async { Ok(()) }, + ) + .await + .unwrap(); + replacement = Some(fixture.running(relay)); + } + + _ => {} + } + resume.send(()).unwrap(); + let result = future.await; + if mutation == "unchanged" { + result.unwrap(); + let new_runtime = fixture.running(relay); + assert_ne!(new_runtime.1, old_runtime.1); + assert_eq!(new_runtime.2, Some(target.reference())); + } else { + assert!(result.is_err()); + assert_eq!(fixture.running(relay), replacement.unwrap_or(old_runtime)); + } + } + } +} + +#[tokio::test] +async fn direct_start_and_reconcile_continuation_refuse_selection_change_and_stop() { + let _guard = agents::lock_path_mutex(); + for explicit in [true, false] { + for mutation in ["selection", "stop"] { + let mut fixture = Fixture::new(); + fixture.persist(); + let (resume, wait) = tokio::sync::oneshot::channel(); + let future = agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + ONE.into(), + None, + explicit, + false, + fixture.app.handle().clone(), + |model, _| async move { + assert_eq!(model, None); + wait.await.unwrap(); + Ok(()) + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + if mutation == "selection" { + fixture.named(ONE, "relay-mesh", "not-preflighted"); + fixture.persist(); + } else { + let state = fixture.app.state::(); + let _transition = state.managed_agent_runtime_transition.lock().unwrap(); + agents::stop_pair_locked( + fixture.record.pubkey.clone(), + ONE.into(), + fixture.app.handle().clone(), + ) + .unwrap(); + } + resume.send(()).unwrap(); + assert!(future.await.is_err()); + fixture.no_launch(); + } + } +} + +#[cfg(feature = "mesh-llm")] +#[tokio::test] +async fn recovery_uses_running_pair_snapshots_not_default_or_next_selection() { + let _guard = agents::lock_path_mutex(); + for default_mesh in [false, true] { + let mut fixture = Fixture::new(); + fixture.hold_children(); + fixture.record.provider = Some(if default_mesh { "relay-mesh" } else { "openai" }.into()); + fixture.named(ONE, "relay-mesh", "running-one"); + fixture.named( + TWO, + if default_mesh { "openai" } else { "relay-mesh" }, + "running-two", + ); + fixture.persist(); + for relay in [ONE, TWO] { + agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + None, + false, + false, + fixture.app.handle().clone(), + |_, _| async { Ok(()) }, + ) + .await + .unwrap(); + } + let app = fixture.app.handle().clone(); + let state = app.state::(); + let mut before = crate::mesh_llm::running_mesh_consumers(&state); + before.sort_by(|a, b| a.0.relay_url.cmp(&b.0.relay_url)); + let expected = if default_mesh { + vec![(ONE, "running-one")] + } else { + vec![(ONE, "running-one"), (TWO, "running-two")] + }; + assert_eq!( + before + .iter() + .map(|(key, _, model)| (key.relay_url.as_str(), model.as_str())) + .collect::>(), + expected + ); + // Both record Default and both next-launch selections change. Neither + // can rewrite the provider/model requirement of a running generation. + fixture.record.provider = Some("openai".into()); + fixture.named(ONE, "openai", "next-one"); + fixture.named(TWO, "relay-mesh", "next-two"); + fixture.persist(); + let mut after = crate::mesh_llm::running_mesh_consumers(&state); + after.sort_by(|a, b| a.0.relay_url.cmp(&b.0.relay_url)); + assert_eq!(before, after); + } +} + +#[tokio::test] +async fn reconcile_keeps_authorization_inputs_without_cross_pair_timestamp_failure() { + let _guard = agents::lock_path_mutex(); + let mut fixture = Fixture::new(); + fixture.hold_children(); + fixture.named(ONE, "relay-mesh", "mesh-one"); + fixture.named(TWO, "relay-mesh", "mesh-two"); + fixture.record.last_stopped_at = Some("prior-stop".into()); + fixture.persist(); + let probed_record = fixture.record.clone(); + for (relay, model) in [(ONE, "mesh-one"), (TWO, "mesh-two")] { + agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + Some(&probed_record), + false, + false, + fixture.app.handle().clone(), + |actual, _| async move { + assert_eq!(actual.as_deref(), Some(model)); + Ok(()) + }, + ) + .await + .unwrap(); + } + assert_ne!(fixture.running(ONE).1, fixture.running(TWO).1); +} + +#[tokio::test] +async fn bulk_stop_during_preflight_cannot_be_resumed_by_automatic_start() { + let _guard = agents::lock_path_mutex(); + let mut fixture = Fixture::new(); + fixture.named(ONE, "relay-mesh", "mesh-one"); + fixture.named(TWO, "relay-mesh", "mesh-two"); + fixture.persist(); + let app = fixture.app.handle().clone(); + let state = app.state::(); + let relays = vec![ONE.into(), TWO.into()]; + let (resume, wait) = tokio::sync::oneshot::channel(); + let wait = RefCell::new(Some(wait)); + let future = crate::commands::start_local_agent_pairs_with_preflight_using( + &app, + &state, + &fixture.record.pubkey, + &relays, + |_, _| { + let wait = wait.borrow_mut().take(); + async move { + if let Some(wait) = wait { + wait.await.unwrap(); + } + Ok(()) + } + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + { + let _transition = state.managed_agent_runtime_transition.lock().unwrap(); + agents::stop_pair_locked(fixture.record.pubkey.clone(), TWO.into(), app.clone()).unwrap(); + } + resume.send(()).unwrap(); + assert!(future.await.unwrap_err().contains("Stop interrupted")); + fixture.no_launch(); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs index 8727dc7c6bd..86ad29ff2e1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs @@ -222,8 +222,8 @@ fn malformed_or_stale_reference_is_rejected_before_launch_resolution() { } #[cfg(unix)] -#[test] -fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { +#[tokio::test] +async fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { use std::os::unix::fs::PermissionsExt; use tauri::Manager; let _guard = agents::lock_path_mutex(); @@ -315,7 +315,7 @@ fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { ] { record.env_vars.insert(key.into(), value.into()); } - let plan = prepare_for_app( + let mut plan = prepare_for_app( app.handle(), &record, Some(&named.reference()), @@ -323,6 +323,23 @@ fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { community, ) .unwrap(); + assert!(plan.require_preflight().is_err()); + let unpreflighted = agents::spawn_agent_child_with_broker( + app.handle(), + &record, + community, + true, + Some(&owner), + None, + None, + None, + Some(&plan), + ) + .unwrap_err(); + assert!(unpreflighted.contains("has not completed provider preflight")); + preflight_with(&mut plan, &owner, community, false, |_, _| async { Ok(()) }) + .await + .unwrap(); // Team content and session partitioning changed while preflight was awaiting. // This launch uses the prepared values; a later preparation sees the edits. teams[0].instructions = Some("next launch instructions".into()); @@ -481,3 +498,7 @@ fn ordinary_selection_fence_includes_default() { .unwrap(); assert!(check_selection(&record, Some(&owner), "one", Some(&named.reference())).is_err()); } + +#[cfg(unix)] +#[path = "orchestration_tests.rs"] +mod orchestration; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index db8681331d9..4a77b0d0148 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -181,6 +181,10 @@ pub(crate) fn effective_effort(descriptor: &EffectiveHarnessDescriptor) -> Optio impl SpawnConfigSnapshot { /// Assemble the snapshot from values a spawn has already resolved. + pub(crate) fn relay_mesh_model_id(&self) -> Option { + super::resolved_relay_mesh_model_id(self.provider.as_deref(), self.model.as_deref()) + } + pub(crate) fn from_inputs(inputs: SpawnConfigInputs<'_>) -> Self { let SpawnConfigInputs { record, diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index e206c53886a..da70a841bcf 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -26,8 +26,8 @@ pub use progress::install_progress_sink; mod recovery; pub use recovery::MeshRecoveryState; pub(crate) use recovery::{ - rearm_relay_mesh_for_running_agents, recover_stale_mesh_runtime, MeshRecoveryUrgency, - MeshRuntimeRecovery, + rearm_relay_mesh_for_running_agents, recover_stale_mesh_runtime, running_mesh_consumers, + MeshRecoveryUrgency, MeshRuntimeRecovery, }; mod usage; diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 6398f472505..768a9c47e86 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::time::Duration; @@ -275,12 +274,9 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu .as_ref() .map(|runtime| runtime.mode()); let recovery = recover_stale_mesh_runtime(&state, MeshRecoveryUrgency::Watchdog).await; - let active_pubkeys = active_managed_agent_pubkeys(&state); - // Mesh participation is resolved through the same definition-authoritative - // path as spawn/restore (#1968): definition → global fallback. A linked - // instance's own bytes never contribute. - let personas = crate::managed_agents::load_personas(app).unwrap_or_default(); - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + // Runtime snapshots, not durable Default or the next selected configuration. + // Keep the pair and generation through asynchronous ingress repair. + let consumers = running_mesh_consumers(&state); match recovery { MeshRuntimeRecovery::Live @@ -294,10 +290,7 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu app.request_restart(); return Ok(()); } - let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); - if !records.iter().any(|record| { - running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() - }) { + if consumers.is_empty() { // A foreground save may still be bringing up its first ingress. // Only an already-running consumer justifies an automatic app // relaunch from the background watchdog. @@ -315,26 +308,18 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu )); } MeshRuntimeRecovery::Absent => { - let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); - if !records.iter().any(|record| { - running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() - }) { + if consumers.is_empty() { return Ok(()); } } MeshRuntimeRecovery::Evicted => {} } - let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); - let mesh_records: Vec<_> = records - .into_iter() - .filter_map(|record| { - running_relay_mesh_model_id(&record, &active_pubkeys, &personas, &global) - .map(|mesh_model_id| (record, mesh_model_id)) - }) - .collect(); let mut first_error = None; - for (record, mesh_model_id) in &mesh_records { + for (key, nonce, mesh_model_id) in &consumers { + if !is_current_mesh_consumer(&state, key, nonce) { + continue; + } match crate::commands::mesh_llm::ensure_relay_mesh_for_record( app, Some(mesh_model_id.as_str()), @@ -343,15 +328,29 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu .await { Ok(()) => { - if let Err(error) = clear_mesh_last_error_if_set(app, &record.pubkey) { + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + if !is_current_mesh_consumer(&state, key, nonce) { + continue; + } + if let Err(error) = clear_mesh_last_error_if_set(app, &key.pubkey) { eprintln!("buzz-mesh: failed to clear recovery error: {error}"); } } Err(error) => { + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + if !is_current_mesh_consumer(&state, key, nonce) { + continue; + } let message = format!( "{MESH_REARM_ERROR_SENTINEL}Buzz shared compute offline — failed to re-arm local ingress for this agent: {error}" ); - if let Err(persist_error) = persist_mesh_last_error(app, &record.pubkey, &message) { + if let Err(persist_error) = persist_mesh_last_error(app, &key.pubkey, &message) { eprintln!("buzz-mesh: failed to persist recovery error: {persist_error}"); } first_error.get_or_insert(message); @@ -361,41 +360,47 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu first_error.map_or(Ok(()), Err) } -fn active_managed_agent_pubkeys(state: &AppState) -> HashSet { +pub(crate) fn running_mesh_consumers( + state: &AppState, +) -> Vec<( + crate::managed_agents::ManagedAgentRuntimeKey, + String, + String, +)> { state .managed_agent_processes .lock() - .map(|guard| { - guard - .keys() - .map(|key| key.pubkey.to_ascii_lowercase()) + .map(|mut runtimes| { + runtimes + .iter_mut() + .filter_map(|(key, runtime)| { + if !matches!(runtime.child.try_wait(), Ok(None)) { + return None; + } + runtime + .spawn_config + .relay_mesh_model_id() + .map(|model| (key.clone(), runtime.start_nonce.clone(), model)) + }) .collect() }) .unwrap_or_default() } -/// Effective mesh model for a record that is actively running, or `None` -/// when the record is not a running relay-mesh consumer. Resolution goes -/// through `resolve_effective_relay_mesh_model_id` (definition → global -/// fallback, #1968) so the watchdog agrees with spawn/restore about which -/// agents are mesh-backed. -fn running_relay_mesh_model_id( - record: &crate::managed_agents::ManagedAgentRecord, - active_pubkeys: &HashSet, - personas: &[crate::managed_agents::AgentDefinition], - global: &crate::managed_agents::GlobalAgentConfig, -) -> Option { - let running = record.backend == crate::managed_agents::BackendKind::Local - && active_pubkeys.contains(&record.pubkey.to_ascii_lowercase()) - && record - .runtime_pid - .is_none_or(crate::managed_agents::process_is_running); - if !running { - return None; - } - crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( - record, personas, global, - ) +fn is_current_mesh_consumer( + state: &AppState, + key: &crate::managed_agents::ManagedAgentRuntimeKey, + nonce: &str, +) -> bool { + state + .managed_agent_processes + .lock() + .map(|mut runtimes| { + runtimes.get_mut(key).is_some_and(|runtime| { + runtime.start_nonce == nonce && matches!(runtime.child.try_wait(), Ok(None)) + }) + }) + .unwrap_or(false) } fn persist_mesh_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), String> { @@ -435,58 +440,6 @@ fn clear_mesh_last_error_if_set(app: &AppHandle, pubkey: &str) -> Result<(), Str mod tests { use super::*; - fn mesh_record( - pubkey: &str, - runtime_pid: Option, - ) -> crate::managed_agents::ManagedAgentRecord { - let mut record = crate::managed_agents::AgentDefinition { - id: pubkey.to_string(), - display_name: pubkey.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: None, - model: None, - provider: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - team_catalog_source: None, - env_vars: std::collections::BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1/".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "2026-01-01T00:00:00Z".to_string(), - updated_at: "2026-01-01T00:00:00Z".to_string(), - } - .into_agent_record(); - record.pubkey = pubkey.to_string(); - record.backend = crate::managed_agents::BackendKind::Local; - record.runtime_pid = runtime_pid; - record - } - - fn active_set(pubkeys: &[&str]) -> HashSet { - pubkeys - .iter() - .map(|pubkey| pubkey.to_ascii_lowercase()) - .collect() - } - #[tokio::test] async fn closed_port_is_distinct_from_unhealthy_bound_port() { assert_eq!( @@ -622,49 +575,6 @@ mod tests { )); } - #[test] - fn only_running_relay_mesh_agents_trigger_rearm() { - let personas: Vec = Vec::new(); - let global = crate::managed_agents::GlobalAgentConfig::default(); - - let empty = active_set(&[]); - assert!(running_relay_mesh_model_id( - &mesh_record("stopped", Some(std::process::id())), - &empty, - &personas, - &global, - ) - .is_none()); - - let active = active_set(&["live"]); - assert_eq!( - running_relay_mesh_model_id( - &mesh_record("live", Some(std::process::id())), - &active, - &personas, - &global, - ) - .as_deref(), - Some("Qwen3") - ); - assert_eq!( - running_relay_mesh_model_id(&mesh_record("live", None), &active, &personas, &global) - .as_deref(), - Some("Qwen3") - ); - - let mut non_mesh = mesh_record("plain", Some(std::process::id())); - non_mesh.env_vars.clear(); - non_mesh.relay_mesh = None; - assert!(running_relay_mesh_model_id( - &non_mesh, - &active_set(&["plain"]), - &personas, - &global, - ) - .is_none()); - } - #[test] fn recovery_error_sentinel_does_not_match_unrelated_errors() { assert!( diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 7c76275dbf4..c22cc8b0906 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -438,3 +438,13 @@ selection. Unavailable choices never remove independent Stop controls. Model IDs are explicit authored values; credentials are provisioned locally, never entered or copied by this editor. See `docs/named-runtime-configurations.md` for the native contract and the distinction between fixture checks and real execution evidence. + +All ordinary launch consumers (including Default, bulk restart, direct pairs and +restore) capture configuration before async provider preflight. Shared native +spawn requires that preflighted plan; it never reselects a configuration. +Recovery reads the actual running pair snapshot, not next-launch selection. +Pair Restart is one native preflight/locked Stop/spawn operation, never frontend +Stop then Start. A refused preflight leaves the old process and turns intact; +a successful Stop followed by failed launch returns an existing Failed status. +The frontend clears only turn IDs captured before that native operation, so new +replacement turns are safe even when its result arrives after they start. diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index ebafb3f1976..36a1c12bee8 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -699,6 +699,26 @@ export function clearActiveTurnsForAgent(agentPubkey: string): void { notifyListeners(); } +/** Capture only this generation's known turns before a native atomic restart. + * Clearing after restart cannot tombstone turns started by the replacement. */ +export function captureActiveTurnsForAgentClear( + agentPubkey: string, +): () => void { + const key = normalizePubkey(agentPubkey); + const captured = [...(activeTurnsByAgent.get(key)?.keys() ?? [])]; + return () => { + const turns = activeTurnsByAgent.get(key); + const agentClockNow = Date.now() - (clockOffsetByAgent.get(key) ?? 0); + for (const turnId of captured) { + recordTerminal(key, turnId, agentClockNow); + turns?.delete(turnId); + } + if (turns?.size === 0) activeTurnsByAgent.delete(key); + invalidateCache(key); + notifyListeners(); + }; +} + /** * Clears all live turn state (active turns, offsets, watermarks, tombstones). * Intentionally preserves `savedByCommunity` — community-switch snapshots diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs index 3e961b58544..2356f720bc4 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs @@ -2,109 +2,88 @@ import assert from "node:assert/strict"; import test from "node:test"; import { restartManagedAgentPair } from "./managedAgentRuntimeHooks.ts"; - -// --------------------------------------------------------------------------- -// restartManagedAgentPair: discriminating regression tests for the pair -// restart lifecycle boundary (stop → relay-scoped clear → start). -// -// These tests exercise the exact function called by useManagedAgentRuntimeAction's -// mutationFn restart branch, so reverting to the old combined Rust command -// (which cleared only in onSuccess, after the new process was already running) -// would make tests (a) and (c) fail. -// --------------------------------------------------------------------------- +import { + captureActiveTurnsForAgentClear, + getActiveTurnsForAgent, + resetActiveAgentTurnsStore, + syncAgentTurnsFromEvents, +} from "./activeAgentTurnsStore.ts"; const PUBKEY = "deadbeef".repeat(8); const RELAY = "wss://relay.example"; +const status = (lifecycle = "running") => ({ + pubkey: PUBKEY, + relayUrl: RELAY, + localSetup: true, + lifecycle, +}); -/** Returns a resolved-status stub sufficient for the return-type assertion. */ -function makeStatus() { - return { - pubkey: PUBKEY, - relayUrl: RELAY, - localSetup: true, - lifecycle: "running", - }; -} - -test("test_pair_restart_stop_success_start_failure_clear_still_ran", async () => { - // Stop succeeds, start throws. The clear must have fired — badge is gone - // regardless of the start failure. On the old combined-command approach, - // a rejected command meant onSuccess never ran and the badge survived. - let clearFired = false; - +test("restart preflight refusal leaves old turns alone", async () => { + const calls = []; await assert.rejects( restartManagedAgentPair( PUBKEY, RELAY, - async () => makeStatus(), // stop succeeds - (_pubkey, _relayUrl) => { - clearFired = true; + async (pubkey, relay) => { + assert.equal(pubkey, PUBKEY); + assert.equal(relay, RELAY); + calls.push("native-restart"); + throw new Error("selected provider unavailable"); }, - async () => { - throw new Error("start failed"); + () => { + calls.push("capture-old-turns"); + return () => calls.push("clear"); }, ), - /start failed/, - ); - - assert.ok( - clearFired, - "clear must fire at stop-success boundary even when start subsequently fails", + /selected provider unavailable/, ); + assert.deepEqual(calls, ["capture-old-turns", "native-restart"]); }); -test("test_pair_restart_stop_failure_neither_clear_nor_start_called", async () => { - // Stop throws. Neither clear nor start should run — clearing on a failed - // stop would remove a badge that is still legitimately active. - let clearFired = false; - let startCalled = false; - +test("successful Stop with failed replacement retires old turns and reports failure", async () => { + let cleared = false; await assert.rejects( restartManagedAgentPair( PUBKEY, RELAY, - async () => { - throw new Error("stop failed"); - }, - (_pubkey, _relayUrl) => { - clearFired = true; - }, - async () => { - startCalled = true; - return makeStatus(); + async () => ({ ...status("failed"), error: "replacement failed" }), + () => () => { + cleared = true; }, ), - /stop failed/, + /replacement failed/, ); - - assert.ok(!clearFired, "clear must NOT fire when stop itself fails"); - assert.ok(!startCalled, "start must NOT be called when stop fails"); + assert.equal(cleared, true); }); -test("test_pair_restart_strict_stop_clear_start_ordering", async () => { - // Verify the operations fire in the guaranteed order: stop → clear → start. - // A clear that fires after start begins can tombstone genuine new turns. - const events = []; - - await restartManagedAgentPair( +test("native restart clears only captured turns, not replacement turns", async () => { + resetActiveAgentTurnsStore(); + const event = (turnId, channelId, seq) => ({ + seq, + timestamp: new Date(Date.now() + seq).toISOString(), + kind: "turn_started", + agentIndex: 0, + channelId, + sessionId: "session", + turnId, + payload: null, + }); + syncAgentTurnsFromEvents(PUBKEY, [event("old", "old-channel", 1)]); + const result = await restartManagedAgentPair( PUBKEY, RELAY, async () => { - events.push("stop"); - return makeStatus(); - }, - (_pubkey, _relayUrl) => { - events.push("clear"); - }, - async () => { - events.push("start"); - return makeStatus(); + syncAgentTurnsFromEvents(PUBKEY, [ + event("replacement", "new-channel", 2), + ]); + return status(); }, + (pubkey) => captureActiveTurnsForAgentClear(pubkey), ); - + assert.equal(result.lifecycle, "running"); assert.deepEqual( - events, - ["stop", "clear", "start"], - "operations must fire in stop → clear → start order", + getActiveTurnsForAgent(PUBKEY).map((turn) => turn.channelId), + ["new-channel"], ); + resetActiveAgentTurnsStore(); }); diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 21dfe9a94fe..65826e015d9 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -5,7 +5,10 @@ import { type QueryClient, } from "@tanstack/react-query"; -import { clearActiveTurnsForAgent } from "@/features/agents/activeAgentTurnsStore"; +import { + clearActiveTurnsForAgent, + captureActiveTurnsForAgentClear, +} from "@/features/agents/activeAgentTurnsStore"; import { loadActiveCommunityId, loadCommunities, @@ -13,6 +16,7 @@ import { import { listManagedAgentRuntimes, reconcileManagedAgentRuntimes, + restartManagedAgentRuntime, startManagedAgentRuntime, stopManagedAgentRuntime, } from "@/shared/api/tauriManagedAgents"; @@ -147,35 +151,44 @@ export function clearActiveTurnsForAgentOnStop( clearActiveTurnsForAgent(pubkey); } -/** - * Execute a pair restart as stop → relay-scoped badge clear → start. - * - * Extracted from `useManagedAgentRuntimeAction`'s `mutationFn` so the - * three-step lifecycle boundary can be tested directly without a hook-render - * harness. All three operations are injected, keeping this function free of - * React and Tauri imports. - * - * Guarantees: - * - Clear fires only when stop succeeds. - * - A failed start occurs after the clear — the badge is already gone. - * - No clear can fire after start begins, so genuinely-new turns are safe. - */ +/** A native restart owns capture → preflight → locked Stop/spawn. Never split + * it into frontend Stop/Start, which destroys the old child before validation. */ export async function restartManagedAgentPair( pubkey: string, relayUrl: string, - stop: ( - pubkey: string, - relayUrl: string, - ) => Promise, - clear: (pubkey: string, relayUrl: string) => void, - start: ( + restart: ( pubkey: string, relayUrl: string, ) => Promise, + captureClear: (pubkey: string, relayUrl: string) => () => void, ): Promise { - await stop(pubkey, relayUrl); - clear(pubkey, relayUrl); - return start(pubkey, relayUrl); + const clearOldTurns = captureClear(pubkey, relayUrl); + const status = await restart(pubkey, relayUrl); + // Native returns Failed only after successful Stop + failed replacement. + // A preflight/admission/Stop failure throws and must not clear live old turns. + clearOldTurns(); + if (status.lifecycle === "failed") { + throw new Error(status.error ?? "Agent stopped but replacement failed"); + } + return status; +} + +function capturePairTurnsClear(pubkey: string, relayUrl: string): () => void { + const activeId = loadActiveCommunityId(); + const community = loadCommunities().find((c) => c.id === activeId); + const relay = canonicalRelayUrl(relayUrl); + if ( + !activeId || + !community || + !relay || + canonicalRelayUrl(community.relayUrl) !== relay + ) { + return () => {}; + } + const clear = captureActiveTurnsForAgentClear(pubkey); + return () => { + if (loadActiveCommunityId() === activeId) clear(); + }; } export function useManagedAgentRuntimeAction() { @@ -197,16 +210,15 @@ export function useManagedAgentRuntimeAction() { return restartManagedAgentPair( pubkey, relayUrl, - stopManagedAgentRuntime, - clearActiveTurnsForAgentOnStop, - startManagedAgentRuntime, + restartManagedAgentRuntime, + capturePairTurnsClear, ); } return startManagedAgentRuntime(pubkey, relayUrl, explicitStart); }, onSuccess: (runtime, { action }) => { // For stop-only: clear stale working badges immediately. The restart - // path already clears at the stop-success boundary inside mutationFn. + // path retires only its captured old turns inside mutationFn. if (action === "stop") { clearActiveTurnsForAgentOnStop(runtime.pubkey, runtime.relayUrl); } From ee6395b94e821829227e9445c1e62133a68fa5fd Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 7 Sep 2026 12:32:08 -0400 Subject: [PATCH 34/51] refactor(desktop): satisfy runtime integration file-size ratchet Move the existing descriptor resolver and discovery record fixture into sibling modules without changing their behavior or dropping cases. The required CI changes job rejects their producer-added growth in inherited oversized files; no policy limits or allowlists changed. Native fmt and actual size ratchet pass; native compilation is still awaiting CI-only draft authorization. Signed-off-by: Logan Johnson --- .../src/managed_agents/discovery/tests.rs | 71 +---------- .../discovery/tests/record_fixture.rs | 68 ++++++++++ .../src-tauri/src/managed_agents/readiness.rs | 114 +---------------- .../managed_agents/readiness/descriptor.rs | 116 ++++++++++++++++++ 4 files changed, 189 insertions(+), 180 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/record_fixture.rs create mode 100644 desktop/src-tauri/src/managed_agents/readiness/descriptor.rs diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 78634880fe5..65ca72b94d5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -1,5 +1,8 @@ use std::path::PathBuf; +mod record_fixture; +use record_fixture::record_with; + use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ apply_agent_command_update, apply_env_vars_then_effort_transition, classify_runtime, @@ -205,74 +208,6 @@ fn effective_agent_command_explicit_override_wins() { ); } -/// Minimal record for `record_agent_command` tests; only resolution inputs vary. -fn record_with( - runtime: Option<&str>, - persona_id: Option<&str>, - override_cmd: Option<&str>, -) -> crate::managed_agents::types::ManagedAgentRecord { - crate::managed_agents::types::ManagedAgentRecord { - runtime_configurations: Default::default(), - description: None, - pubkey: String::new(), - name: "r".to_string(), - persona_id: persona_id.map(str::to_string), - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: String::new(), - agent_command: String::new(), - agent_command_override: override_cmd.map(str::to_string), - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 0, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_policy_pending: false, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - env_vars: std::collections::BTreeMap::new(), - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: Default::default(), - respond_to_allowlist: vec![], - display_name: None, - slug: None, - runtime: runtime.map(str::to_string), - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - team_catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - effort_level: None, - } -} - #[test] fn record_agent_command_own_runtime_wins_over_persona() { // A record with its own runtime never consults the persona list. diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/record_fixture.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/record_fixture.rs new file mode 100644 index 00000000000..b05edee0eb2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/record_fixture.rs @@ -0,0 +1,68 @@ +//! Record fixture shared by the existing discovery resolution tests. +/// Minimal record for `record_agent_command` tests; only resolution inputs vary. +pub(super) fn record_with( + runtime: Option<&str>, + persona_id: Option<&str>, + override_cmd: Option<&str>, +) -> crate::managed_agents::types::ManagedAgentRecord { + crate::managed_agents::types::ManagedAgentRecord { + runtime_configurations: Default::default(), + description: None, + pubkey: String::new(), + name: "r".to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: override_cmd.map(str::to_string), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: std::collections::BTreeMap::new(), + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: runtime.map(str::to_string), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + effort_level: None, + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 99043a42f3f..1470eb4f7e6 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -47,12 +47,13 @@ use crate::managed_agents::{ discovery::{known_acp_runtime, KnownAcpRuntime}, env_vars::merged_user_env, global_config::GlobalAgentConfig, - normalize_agent_args, types::{AcpAvailabilityStatus, AgentDefinition, ManagedAgentRecord}, }; mod cli_login; pub(crate) mod cli_probe; +mod descriptor; +pub(crate) use descriptor::{resolve_effective_harness_descriptor, EffectiveHarnessDescriptor}; // ── EffectiveAgentEnv ───────────────────────────────────────────────────────── @@ -78,117 +79,6 @@ pub(crate) struct EffectiveAgentEnv { pub effective_command: String, } -// ── Typed effective-harness descriptor ─────────────────────────────────────── -// -// A single owned type that fully describes what a spawn would run. Produced -// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, -// spawn_snapshot, build_managed_agent_summary, get_agent_models, and -// agent_readiness — so the harness-definition lookup and arg/env resolution -// happen exactly once, in one place. - -/// The complete effective description of a harness spawn: resolved command, -/// args, and layered env. This is the single source of truth for what will -/// actually run — computed once and shared across every consumer that needs -/// the effective values. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct EffectiveHarnessDescriptor { - /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). - /// Used for `known_acp_runtime` lookup and hashing. - pub command: String, - /// Normalized effective args. Instance args win when non-empty; otherwise - /// the harness definition's args apply. - pub args: Vec, - /// The full layered process env: baked floor → runtime metadata → definition - /// env → global → persona → agent. - pub env: BTreeMap, -} - -/// Resolve the complete harness descriptor from a record + context — the single -/// authoritative path for command, args, and env. -/// -/// This is the only place where harness-definition lookup and arg/env layering -/// happen; spawn, hash, summary, and both model-probe paths all consume this. -/// -/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked -/// persona) references a runtime id that no longer exists in the registry — -/// the same typed error produced by `try_record_agent_command`. Callers that -/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) -/// propagate the error; callers that degrade gracefully may use -/// `.unwrap_or_else(|_| …)`. -/// -/// Does NOT require an `AppHandle` so it is fully unit-testable. -/// -/// # Arguments -/// * `record` — the managed agent record -/// * `personas` — all current personas (for command/env resolution) -/// * `global` — global agent config defaults -pub(crate) fn resolve_effective_harness_descriptor( - record: &ManagedAgentRecord, - personas: &[crate::managed_agents::types::AgentDefinition], - global: &crate::managed_agents::GlobalAgentConfig, -) -> Result { - // A transient projection, never persisted back onto the stable agent/persona. - let projected; - let record = if let Some(config) = super::runtime_configurations::selected(record)? { - projected = { - let mut copy = record.clone(); - copy.runtime = Some(config.runtime.clone()); - copy.agent_args.clear(); - copy.agent_command_override = None; - copy - }; - &projected - } else { - record - }; - let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; - let runtime_meta = known_acp_runtime(&effective_command); - - // Look up the harness definition once — used for both args and env. - // Resolution order: record.runtime → persona.runtime → "". - let harness_def = { - let runtime_id = record - .runtime - .as_deref() - .or_else(|| { - record.persona_id.as_deref().and_then(|pid| { - personas - .iter() - .find(|p| p.id == pid) - .and_then(|p| p.runtime.as_deref()) - }) - }) - .unwrap_or(""); - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) - }; - - // Args: explicit non-empty instance args win; otherwise use definition args. - let args = { - let record_args = record.agent_args.clone(); - let instance_has_args = record_args.iter().any(|a| !a.trim().is_empty()); - if instance_has_args { - normalize_agent_args(&effective_command, record_args) - } else if let Some(ref def) = harness_def { - normalize_agent_args(&effective_command, def.args.clone()) - } else { - normalize_agent_args(&effective_command, record_args) - } - }; - - // Env: full layered resolution (same as resolve_effective_agent_env). - // Pass harness_def directly to avoid a second lookup. - let effective_env = - resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); - - let mut descriptor = EffectiveHarnessDescriptor { - command: effective_command, - args, - env: effective_env.env, - }; - super::runtime_configurations::apply_descriptor(record, &mut descriptor)?; - Ok(descriptor) -} - /// Assemble the effective agent env from a record, personas, optional /// known-runtime metadata, and the global agent config defaults — without an /// `AppHandle` so it is fully unit-testable. diff --git a/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs b/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs new file mode 100644 index 00000000000..cc48673b16f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs @@ -0,0 +1,116 @@ +//! Immutable harness descriptor resolution shared by launch and inspection. +use super::{resolve_effective_agent_env_with_def, ManagedAgentRecord}; +use crate::managed_agents::{discovery::known_acp_runtime, normalize_agent_args}; +use std::collections::BTreeMap; + +// ── Typed effective-harness descriptor ─────────────────────────────────────── +// +// A single owned type that fully describes what a spawn would run. Produced +// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, +// spawn_snapshot, build_managed_agent_summary, get_agent_models, and +// agent_readiness — so the harness-definition lookup and arg/env resolution +// happen exactly once, in one place. + +/// The complete effective description of a harness spawn: resolved command, +/// args, and layered env. This is the single source of truth for what will +/// actually run — computed once and shared across every consumer that needs +/// the effective values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EffectiveHarnessDescriptor { + /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). + /// Used for `known_acp_runtime` lookup and hashing. + pub command: String, + /// Normalized effective args. Instance args win when non-empty; otherwise + /// the harness definition's args apply. + pub args: Vec, + /// The full layered process env: baked floor → runtime metadata → definition + /// env → global → persona → agent. + pub env: BTreeMap, +} + +/// Resolve the complete harness descriptor from a record + context — the single +/// authoritative path for command, args, and env. +/// +/// This is the only place where harness-definition lookup and arg/env layering +/// happen; spawn, hash, summary, and both model-probe paths all consume this. +/// +/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked +/// persona) references a runtime id that no longer exists in the registry — +/// the same typed error produced by `try_record_agent_command`. Callers that +/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) +/// propagate the error; callers that degrade gracefully may use +/// `.unwrap_or_else(|_| …)`. +/// +/// Does NOT require an `AppHandle` so it is fully unit-testable. +/// +/// # Arguments +/// * `record` — the managed agent record +/// * `personas` — all current personas (for command/env resolution) +/// * `global` — global agent config defaults +pub(crate) fn resolve_effective_harness_descriptor( + record: &ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> Result { + // A transient projection, never persisted back onto the stable agent/persona. + let projected; + let record = + if let Some(config) = crate::managed_agents::runtime_configurations::selected(record)? { + projected = { + let mut copy = record.clone(); + copy.runtime = Some(config.runtime.clone()); + copy.agent_args.clear(); + copy.agent_command_override = None; + copy + }; + &projected + } else { + record + }; + let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; + let runtime_meta = known_acp_runtime(&effective_command); + + // Look up the harness definition once — used for both args and env. + // Resolution order: record.runtime → persona.runtime → "". + let harness_def = { + let runtime_id = record + .runtime + .as_deref() + .or_else(|| { + record.persona_id.as_deref().and_then(|pid| { + personas + .iter() + .find(|p| p.id == pid) + .and_then(|p| p.runtime.as_deref()) + }) + }) + .unwrap_or(""); + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) + }; + + // Args: explicit non-empty instance args win; otherwise use definition args. + let args = { + let record_args = record.agent_args.clone(); + let instance_has_args = record_args.iter().any(|a| !a.trim().is_empty()); + if instance_has_args { + normalize_agent_args(&effective_command, record_args) + } else if let Some(ref def) = harness_def { + normalize_agent_args(&effective_command, def.args.clone()) + } else { + normalize_agent_args(&effective_command, record_args) + } + }; + + // Env: full layered resolution (same as resolve_effective_agent_env). + // Pass harness_def directly to avoid a second lookup. + let effective_env = + resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); + + let mut descriptor = EffectiveHarnessDescriptor { + command: effective_command, + args, + env: effective_env.env, + }; + crate::managed_agents::runtime_configurations::apply_descriptor(record, &mut descriptor)?; + Ok(descriptor) +} From 651cb1ba2643a282a58e1ce87a96d8544d4c0d03 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 7 Sep 2026 11:11:16 -0400 Subject: [PATCH 35/51] fix(desktop): retire lifecycle continuations when controls are canceled Signed-off-by: Logan Johnson --- .../ui/DesktopLifecycleControl.test.mjs | 122 ++++++++++++++++++ .../agents/ui/DesktopLifecycleControl.tsx | 23 +++- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs index 17aff55e75a..302964c7998 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -261,3 +261,125 @@ test("terminal receiver failure is a scope-owned notification, not pre-shell lay dom.window.close(); } }); + +for (const interruption of ["cancel", "account", "community", "unmount"]) { + test(`mounted ${interruption} retires a pending Stop and never starts on late confirmation`, async () => { + const dom = new JSDOM("
    ", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const query = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + query.setQueryData( + ["relay-agents"], + [{ pubkey: "agent", name: "Agent", ownerPubkey: "owner" }], + ); + const original = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + }; + const prepared = []; + let confirmStop; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "observe_desktop_placement") return; + if (command === "read_desktop_placement") + return ["source", "selection"]; + if (command.startsWith("prepare_desktop_")) { + const request = { + id: `request-${prepared.length}`, + kind: command.endsWith("_stop") ? 50180 : 50182, + ...args, + }; + prepared.push(request); + return request; + } + if (command === "read_desktop_lifecycle_results") return "running"; + if (command === "read_desktop_stop_results") + return new Promise((resolve) => { + confirmStop = resolve; + }); + throw Error(command); + }, + }; + relayClient.fetchEvents = async () => []; + relayClient.publishEvent = async (_event, _timeout, _failure, check) => + check(); + const root = createRoot(document.getElementById("root")); + const scope = { owner: "owner", community: "wss://one.example" }; + const render = (nextScope) => + root.render( + React.createElement( + QueryClientProvider, + { client: query }, + React.createElement(DesktopLifecycleControl, { + scope: nextScope, + desktops: [ + { id: "source", name: "Source" }, + { id: "target", name: "Target" }, + ], + }), + ), + ); + const click = async (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + const select = async (label, value) => + React.act(async () => { + const element = document.querySelector(`select[aria-label="${label}"]`); + element.value = value; + element.dispatchEvent( + new dom.window.Event("change", { bubbles: true }), + ); + }); + try { + await React.act(async () => render(scope)); + await select("Agent to place", "agent"); + await select("Destination Desktop", "target"); + await click("Move to destination"); + assert.equal(typeof confirmStop, "function"); + if (interruption === "cancel") await click("Cancel waiting"); + else if (interruption === "unmount") + await React.act(async () => root.unmount()); + else + await React.act(async () => + render({ + ...scope, + [interruption === "account" ? "owner" : "community"]: "changed", + }), + ); + await React.act(async () => confirmStop("stopped")); + assert.equal(prepared.filter((r) => r.action === "start").length, 0); + assert.doesNotMatch( + document.body.textContent, + /Retry same request|Source Stop confirmed/, + ); + if (interruption === "cancel") + assert.match( + document.body.textContent, + /Dispatched operations may still finish/, + ); + if (interruption === "account" || interruption === "community") + assert.equal( + document.querySelector('select[aria-label="Agent to place"]').value, + "", + ); + } finally { + await React.act(async () => root.unmount()); + query.clear(); + relayClient.fetchEvents = original.fetch; + relayClient.publishEvent = original.publish; + dom.window.close(); + } + }); +} diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx index 8212eb7258b..b022c409dd4 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -82,11 +82,16 @@ export function DesktopLifecycleControl({ const generation = useRef(0); useEffect(() => { active.current = true; + setAgent(""); + setDestination(""); + setRequest(null); + setStatus(""); + setBusy(false); return () => { active.current = false; generation.current++; }; - }, []); + }, [scope.owner, scope.community]); const run = async (action: "start" | "restart" | "move" | "retry") => { const token = ++generation.current; const valid = () => active.current && generation.current === token; @@ -215,6 +220,22 @@ export function DesktopLifecycleControl({ > Move to destination + {busy && ( + + )} {request && ( {busy && (