diff --git a/crates/buzz-core/src/desktop_lifecycle.rs b/crates/buzz-core/src/desktop_lifecycle.rs index 8e9ca93186..35debdc67f 100644 --- a/crates/buzz-core/src/desktop_lifecycle.rs +++ b/crates/buzz-core/src/desktop_lifecycle.rs @@ -50,7 +50,8 @@ pub enum Outcome { Running, /// Actual status confirms no managed process at this target. Stopped, - /// Destination-local broker session issuance is not available. + /// Legacy receiver refusal, retained for wire compatibility. New receivers + /// use destination-local credentials and report Ineligible when unavailable. ProvisioningUnavailable, /// Runtime/readiness/ownership rejected the request. Failed, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 7b153be179..c8654c42b4 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -165,7 +165,6 @@ where true, None, None, - None, &plan, true, false, diff --git a/desktop/src-tauri/src/commands/desktop_lifecycle.rs b/desktop/src-tauri/src/commands/desktop_lifecycle.rs index e709ff5a2c..80bd30b479 100644 --- a/desktop/src-tauri/src/commands/desktop_lifecycle.rs +++ b/desktop/src-tauri/src/commands/desktop_lifecycle.rs @@ -5,7 +5,7 @@ use super::{ }; use crate::{ app_state::AppState, - managed_agents::{self, broker_launch, placement, retention::open_retention_db}, + managed_agents::{self, placement, retention::open_retention_db}, }; use buzz_core_pkg::{ desktop_lifecycle::{Action, Outcome, Request, ResultMessage, RuntimeConfigurationRef}, @@ -132,8 +132,8 @@ pub fn read_desktop_placement( placement::latest_start(&open_retention_db(&scope.db_path)?, &agent) } -fn generation( - app: &AppHandle, +fn generation( + app: &AppHandle, state: &AppState, agent: &str, community: &str, @@ -158,7 +158,7 @@ fn generation( .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let records = managed_agents::load_managed_agents(app)?; + let records = managed_agents::storage::load_agent_store(app)?; let legacy = records .iter() .find(|r| r.pubkey == agent) @@ -181,8 +181,8 @@ fn generation( Ok(None) } -fn status( - app: &AppHandle, +fn status( + app: &AppHandle, conn: &rusqlite::Connection, state: &AppState, event: &Event, @@ -201,8 +201,8 @@ fn status( 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, +fn current_observation( + app: &AppHandle, conn: &rusqlite::Connection, state: &AppState, request: &Request, @@ -236,6 +236,37 @@ pub async fn receive_desktop_lifecycle( community: String, event: Event, ) -> Result, String> { + let preflight_app = app.clone(); + receive_desktop_lifecycle_with(app, owner, community, event, move |model, allow| { + let app = preflight_app.clone(); + 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 _ = (app, model, allow); + Ok(()) + } + } + }) + .await +} + +// Production receiver with only ordinary provider I/O replaceable in isolated tests. +pub(crate) async fn receive_desktop_lifecycle_with( + app: AppHandle, + owner: String, + community: String, + event: Event, + preflight: F, +) -> Result, String> +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ // Authenticate and fence the destination before reading any configuration or // doing ordinary async preflight. Never hold the transition lock across await. let request = { @@ -259,18 +290,23 @@ pub async fn receive_desktop_lifecycle( &request.target.agent, )?; let fresh = nostr::Timestamp::now().as_secs() < event.created_at.as_secs().saturating_add(30); - let prepared = if owned + let mut prepared = if owned && fresh && matches!( request.action, Action::Start | Action::Restart | Action::Preflight ) { - configurations::prepare(&app, &owner, &request).await + configurations::prepare(&app, &owner, &request, &preflight).await } else { Err(Outcome::Ineligible) }; + if let Ok(captured) = &mut prepared { + captured + .plan + .expire_at(event.created_at.as_secs().saturating_add(30)); + } let catalog = if owned && fresh && request.action == Action::Catalog { - configurations::catalog(&app, &owner, &request).await + configurations::catalog(&app, &owner, &request, &preflight).await } else { Err("Catalog is unavailable".into()) }; @@ -333,8 +369,8 @@ pub async fn receive_desktop_lifecycle( .map_err(|e| format!("Desktop lifecycle task failed: {e}"))? } -fn execute( - app: &AppHandle, +fn execute( + app: &AppHandle, state: &AppState, conn: &rusqlite::Connection, owner: &str, @@ -367,19 +403,6 @@ fn execute( if configurations::revalidate(app, owner, request, plan).is_err() { return Ok(Outcome::Ineligible); } - // 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, - }, - plan.plan.record(), - ) { - Ok(b) => b, - Err(outcome) => return Ok(outcome), - }; // Shared admission validates the captured Stop fence and generation BEFORE // destructive Restart. The transition lock spans that Stop, launch and receipt. if placement::blocked(conn, &target.agent, &target.desktop)? { @@ -391,7 +414,6 @@ fn execute( true, None, plan.resume.as_ref(), - Some(&broker), &plan.plan, false, // Explicit wire reference, not the destination's next selection. request.action == Action::Restart, diff --git a/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs b/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs index 39bf5b07f5..89716e31ae 100644 --- a/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs +++ b/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs @@ -10,23 +10,32 @@ pub(super) struct CapturedLaunch { pub(super) generation: Option, } -fn record(app: &AppHandle, agent: &str) -> Result { +fn record( + app: &AppHandle, + agent: &str, +) -> Result { let state = app.state::(); let _store = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - managed_agents::load_managed_agents(app)? + managed_agents::storage::load_managed_agents_for_launch(app)? .into_iter() .find(|r| r.pubkey == agent) .ok_or_else(|| "Agent is not provisioned on this Desktop".into()) } -pub(super) async fn prepare( - app: &AppHandle, +pub(super) async fn prepare( + app: &AppHandle, owner: &str, request: &Request, -) -> Result { + preflight: &F, +) -> Result +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ let reference = request.configuration.as_ref().ok_or(Outcome::Ineligible)?; let mut captured = (|| -> Result { let state = app.state::(); @@ -72,39 +81,20 @@ pub(super) async fn prepare( }) })() .map_err(|_| Outcome::Ineligible)?; - // Do not advertise an eligible Switch that is known to fail after source Stop. - broker_launch::provision( - broker_launch::LaunchScope { - owner, - community: &request.target.community, - agent: &request.target.agent, - }, - captured.plan.record(), - )?; configurations::preflight_with( &mut captured.plan, owner, &request.target.community, false, - |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 _ = (app, model, allow); - Ok(()) - } - }, + preflight, ) .await .map_err(|_| Outcome::Ineligible)?; Ok(captured) } -pub(super) fn revalidate( - app: &AppHandle, +pub(super) fn revalidate( + app: &AppHandle, owner: &str, request: &Request, captured: &CapturedLaunch, @@ -122,11 +112,17 @@ pub(super) fn revalidate( ) } -pub(super) async fn catalog( - app: &AppHandle, +pub(super) async fn catalog( + app: &AppHandle, owner: &str, request: &Request, -) -> Result { + preflight: &F, +) -> Result +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ let record = record(app, &request.target.agent)?; let mut entries = configurations::catalog_for_app(app, &record, owner, &request.target.community)? @@ -177,7 +173,18 @@ pub(super) async fn catalog( cursor: None, ..request.clone() }; - entry.eligible = prepare(app, owner, &probe).await.is_ok(); + entry.eligible = match prepare(app, owner, &probe, preflight).await { + Ok(captured) => { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + scope(app, &state, owner, &request.target.community)?; + revalidate(app, owner, &probe, &captured).is_ok() + } + Err(_) => false, + }; } } Ok(CatalogPage { entry, next }) diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs index caec1f9af8..a8c19e324f 100644 --- a/desktop/src-tauri/src/commands/desktop_profiles.rs +++ b/desktop/src-tauri/src/commands/desktop_profiles.rs @@ -8,8 +8,8 @@ use tauri::{AppHandle, State}; use crate::app_state::AppState; use crate::managed_agents::retention::{active_retention_scope, open_retention_db, RetentionScope}; -pub(super) fn scope( - app: &AppHandle, +pub(super) fn scope( + app: &AppHandle, state: &AppState, owner: &str, community: &str, diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs index d2361b92cf..2b15153dfe 100644 --- a/desktop/src-tauri/src/commands/desktop_stop.rs +++ b/desktop/src-tauri/src/commands/desktop_stop.rs @@ -61,6 +61,15 @@ pub async fn receive_desktop_stop( owner: String, community: String, event: Event, +) -> Result, String> { + receive_desktop_stop_for_app(app, owner, community, event).await +} + +pub(crate) async fn receive_desktop_stop_for_app( + app: AppHandle, + owner: String, + community: String, + event: Event, ) -> Result, String> { tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -144,8 +153,8 @@ pub fn read_desktop_stop_results( } /// Local possession/profile alone never establishes owner authority. -pub(super) fn owned_local( - app: &AppHandle, +pub(super) fn owned_local( + app: &AppHandle, state: &AppState, owner: &str, agent: &str, @@ -154,7 +163,7 @@ pub(super) fn owned_local( .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let records = managed_agents::load_managed_agents(app)?; + let records = managed_agents::storage::load_agent_store(app)?; Ok(records.iter().find(|r| r.pubkey == agent).is_some_and(|r| { r.backend == managed_agents::BackendKind::Local && r.auth_tag diff --git a/desktop/src-tauri/src/managed_agents/broker_launch.rs b/desktop/src-tauri/src/managed_agents/broker_launch.rs deleted file mode 100644 index 863e91e13d..0000000000 --- a/desktop/src-tauri/src/managed_agents/broker_launch.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! 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 5a21de30c5..23e6661a32 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -12,7 +12,6 @@ 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/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 841fc01a14..9614bb315c 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -396,7 +396,7 @@ where None, )?; super::terminate_untracked_pair_runtime(app, &key)?; - super::runtime::spawn_agent_child_with_broker( + super::runtime::spawn_agent_child_prepared( app, current, &relay_url, @@ -404,7 +404,6 @@ where owner_hex_ref, None, None, - None, Some(prepared), ) })(); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 9382e8f04b..2b9c806f4a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -473,7 +473,7 @@ pub fn spawn_agent_child( replay_floor_unix: Option, resume: Option<&super::remote_stop::ResumeTicket>, ) -> Result { - spawn_agent_child_with_broker( + spawn_agent_child_prepared( app, record, relay_url, @@ -482,12 +482,11 @@ pub fn spawn_agent_child( replay_floor_unix, resume, None, - None, ) } #[allow(clippy::too_many_arguments)] -pub(crate) fn spawn_agent_child_with_broker( +pub(crate) fn spawn_agent_child_prepared( app: &AppHandle, record: &ManagedAgentRecord, relay_url: &str, @@ -495,18 +494,11 @@ pub(crate) fn spawn_agent_child_with_broker( owner_hex: Option<&str>, 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)?; - 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) { + if let Some(error) = spawn_key_refusal(record) { return Err(error); } let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; @@ -535,7 +527,11 @@ pub(crate) fn spawn_agent_child_with_broker( plan.require_preflight()?; plan.check_scope(owner_hex, relay_url)?; plan.revalidate(record, &personas, &global)?; - let record = &plan.record; + // Keep projected settings immutable, but deliver only the current credential + // that just passed revalidation, never the captured plan's copy. + let mut launch_record = plan.record.clone(); + launch_record.private_key_nsec = record.private_key_nsec.clone(); + let record = &launch_record; let effective_cfg = plan.effective.clone(); let descriptor = &plan.descriptor; let effective_command = &descriptor.command; @@ -876,16 +872,6 @@ pub(crate) fn spawn_agent_child_with_broker( 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, - }, - )?; - } // Applied last so inherited environment cannot weaken explicit model selection. let configuration = super::runtime_configurations::selected(record)?; let required_model = configuration @@ -1024,7 +1010,7 @@ pub(crate) fn start_managed_agent_process_prepared( // 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_with_broker( + let mut process = spawn_agent_child_prepared( app, record, workspace_relay.as_str(), @@ -1032,7 +1018,6 @@ pub(crate) fn start_managed_agent_process_prepared( owner_hex, replay_floor_unix, resume, - None, prepared, )?; let now = now_iso(); diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 2ba8027ebf..2aca58a0fd 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -356,7 +356,6 @@ where true, None, resume.as_ref(), - None, &plan, true, restart, @@ -368,21 +367,6 @@ where .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()) -} - /// Captured automatic starts additionally fence the durable next-launch selection. #[allow(clippy::too_many_arguments)] pub(crate) fn start_pair_captured_locked( @@ -391,7 +375,6 @@ pub(crate) fn start_pair_captured_locked( lazy: bool, expected_updated_at: Option<&str>, resume: Option<&super::remote_stop::ResumeTicket>, - broker: Option<&super::broker_launch::BrokerSession>, plan: &super::runtime_configurations::PreparedLaunch, check_selection: bool, restart: bool, @@ -406,7 +389,11 @@ pub(crate) fn start_pair_captured_locked( .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; + let mut records = if plan.configuration().is_some() { + super::storage::load_managed_agents_for_launch(&app)? + } else { + load_managed_agents(&app)? + }; let record = find_managed_agent_mut(&mut records, &pubkey)?; if record.backend != BackendKind::Local { return Err("managed runtime pairs require a local agent".into()); @@ -480,7 +467,23 @@ pub(crate) fn start_pair_captured_locked( state.clear_agent_session_cache(&key); } let process_result = (|| { - let mut process = super::spawn_agent_child_with_broker( + if plan.configuration().is_some() { + // Stop may take time. A credential revoked during teardown must not + // be resurrected from the captured plan or the pre-Stop record. + record.private_key_nsec.clear(); + let fresh = super::storage::load_managed_agents_for_launch(&app)?; + let current = fresh + .iter() + .find(|r| r.pubkey == record.pubkey) + .ok_or("Agent removed during launch")?; + plan.revalidate( + current, + &load_personas(&app)?, + &load_global_agent_config(&app)?, + )?; + record.private_key_nsec = current.private_key_nsec.clone(); + } + let mut process = super::spawn_agent_child_prepared( &app, record, &relay_url, @@ -488,7 +491,6 @@ pub(crate) fn start_pair_captured_locked( Some(&owner), None, resume, - broker, Some(plan), )?; let mut receipt = ManagedAgentRuntimeReceipt::new( @@ -518,7 +520,11 @@ pub(crate) fn start_pair_captured_locked( status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; status.error = Some(error); drop(runtimes); - save_managed_agents(&app, &records)?; + if plan.configuration().is_some() { + super::storage::save_runtime_metadata(&app, record)?; + } else { + save_managed_agents(&app, &records)?; + } emit_status(&app, &status); return Ok(status); } @@ -534,7 +540,11 @@ pub(crate) fn start_pair_captured_locked( 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)?; + if plan.configuration().is_some() { + super::storage::save_runtime_metadata(&app, record)?; + } else { + save_managed_agents(&app, &records)?; + } emit_status(&app, &status); Ok(status) } diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs index 8a529f033d..ae75279fee 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs @@ -43,6 +43,7 @@ pub(crate) struct PreparedLaunch { pub(super) effective: super::effective_config::EffectiveAgentConfig, legacy_default: bool, preflight_complete: bool, + expires_at: Option, host: String, scope: (String, String), // Catalog-only preparation has no app context and cannot be executed. @@ -60,9 +61,20 @@ impl PreparedLaunch { Ok(()) } + /// Bound a remote request through destructive Stop as well as async preflight. + pub(crate) fn expire_at(&mut self, deadline: u64) { + self.expires_at = Some(self.expires_at.map_or(deadline, |old| old.min(deadline))); + } + /// 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 + .expires_at + .is_some_and(|deadline| nostr::Timestamp::now().as_secs() >= deadline) + { + return Err("Runtime launch request expired".into()); + } if !self.preflight_complete { return Err("Captured runtime launch has not completed provider preflight".into()); } @@ -160,6 +172,7 @@ pub(crate) fn prepare( effective, legacy_default: false, preflight_complete: false, + expires_at: None, host: host.into(), scope: (owner.into(), community.into()), app_inputs: None, @@ -184,6 +197,7 @@ fn prepare_default( record, legacy_default: true, preflight_complete: false, + expires_at: None, host: String::new(), scope: (owner.into(), community.into()), app_inputs: None, @@ -353,10 +367,14 @@ pub(crate) fn preflight( 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() { + if !super::resolve_command(&record.acp_command) + .is_some_and(|path| super::discovery::is_executable_file(&path)) + { return Err("ACP runtime is unavailable".into()); } - if super::resolve_command(&descriptor.command).is_none() { + if !super::resolve_command(&descriptor.command) + .is_some_and(|path| super::discovery::is_executable_file(&path)) + { return Err("Selected runtime is unavailable on this Desktop".into()); } if config.is_some() { 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 05c5b696d0..6023c36afc 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs @@ -325,7 +325,7 @@ async fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() ) .unwrap(); assert!(plan.require_preflight().is_err()); - let unpreflighted = agents::spawn_agent_child_with_broker( + let unpreflighted = agents::spawn_agent_child_prepared( app.handle(), &record, community, @@ -333,7 +333,6 @@ async fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() Some(&owner), None, None, - None, Some(&plan), ) .unwrap_err(); @@ -503,3 +502,6 @@ fn ordinary_selection_fence_includes_default() { #[cfg(unix)] #[path = "orchestration_tests.rs"] mod orchestration; + +#[cfg(all(unix, not(feature = "system-keyring")))] +mod remote_credentials; diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs new file mode 100644 index 0000000000..5dfd4b8730 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs @@ -0,0 +1,533 @@ +//! Signed production receiver → ordinary provider preflight → exact-plan child +//! registration. Only destination-local synthetic identities and bounded fake +//! children; run with --no-default-features (never a real OS credential store). +use super::*; +use buzz_core_pkg::{ + desktop_lifecycle::{Action, Outcome, Request, ResultMessage}, + desktop_stop::{StopOutcome, StopResult, StopTarget}, +}; +use nostr::{Event, EventBuilder, Timestamp}; +use std::{cell::Cell, os::unix::fs::PermissionsExt}; +use tauri::Manager; + +const COMMUNITY: &str = "wss://remote-credential-fixture.example"; + +struct Fixture { + app: tauri::App, + record: ManagedAgentRecord, + owner: nostr::Keys, + named: RuntimeConfiguration, + stamp: Cell, + 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", "BUZZ_PRIVATE_KEY"] + .map(|key| (key, std::env::var_os(key))) + .to_vec(); + let owner = nostr::Keys::generate(); + 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())); + std::env::set_var("BUZZ_PRIVATE_KEY", owner.secret_key().to_secret_hex()); + agents::clear_resolve_cache(); + let mut record = record(); + attest(&mut record, &owner); + // This key exists only in this isolated fixture. The child checks local + // credential delivery without writing a secret to output or diagnostics. + let child = format!( + "#!/bin/sh\n[ \"$BUZZ_PRIVATE_KEY\" = '{}' ] || exit 12\nprintf '%s|%s\\n' \"$BUZZ_ACP_REQUIRED_MODEL\" \"$BUZZ_ACP_MODEL\" >> '{}'\nexec /bin/sleep 20\n", + record.private_key_nsec, temp.path().join("launches").display() + ); + for (name, script) in [ + ("buzz-acp", child.as_str()), + ("buzz-agent", "#!/bin/sh\nexit 0\n"), + ("buzz-dev-mcp", "#!/bin/sh\nexit 0\n"), + ] { + 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(); + *app.state::() + .relay_url_override + .lock() + .unwrap() = Some(COMMUNITY.into()); + assert_eq!( + app.state::() + .signing_keys() + .unwrap() + .public_key(), + owner.public_key() + ); + record.acp_command = temp.path().join("buzz-acp").display().to_string(); + record.runtime = Some("buzz-agent".into()); + record.agent_command = "buzz-agent".into(); + record + .env_vars + .insert("OPENAI_COMPAT_API_KEY".into(), "fixture-only".into()); + let host = local_host(app.handle(), &owner.public_key().to_hex(), COMMUNITY).unwrap(); + let mut named = config(&host); + named.workspace = Some(temp.path().display().to_string()); + let named = save(&mut record, &owner.public_key().to_hex(), COMMUNITY, named); + let fixture = Self { + app, + record, + owner, + named, + stamp: Cell::new(Timestamp::now().as_secs().saturating_sub(10)), + env, + temp, + }; + fixture.persist(); + fixture + } + fn persist(&self) { + agents::save_managed_agents(self.app.handle(), &[self.record.clone()]).unwrap(); + } + fn target(&self) -> StopTarget { + StopTarget { + v: 1, + community: COMMUNITY.into(), + desktop: self.named.host.clone(), + agent: self.record.pubkey.clone(), + } + } + fn ordered(&self, event: Event) -> Event { + let stamp = self.stamp.get() + 1; + self.stamp.set(stamp); + EventBuilder::new(event.kind, event.content) + .tags(event.tags.iter().cloned()) + .custom_created_at(Timestamp::from_secs(stamp)) + .sign_with_keys(&self.owner) + .unwrap() + } + fn request(&self, action: Action, observed: Option) -> Event { + self.ordered( + Request { + target: self.target(), + action, + observed, + configuration: (!matches!(action, Action::Catalog | Action::Status)) + .then(|| self.named.reference()), + cursor: None, + } + .sign(&self.owner) + .unwrap(), + ) + } + async fn receive(&self, event: Event, preflight: F) -> ResultMessage + where + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, + { + let response = crate::commands::receive_desktop_lifecycle_with( + self.app.handle().clone(), + self.owner.public_key().to_hex(), + COMMUNITY.into(), + event.clone(), + preflight, + ) + .await + .unwrap() + .unwrap(); + let result = ResultMessage::read(&response, &self.owner, &event, COMMUNITY).unwrap(); + let safe = serde_json::to_string(&result).unwrap(); + assert!( + !safe.contains(&self.record.private_key_nsec) + || self.record.private_key_nsec.is_empty() + ); + assert!(!safe.contains("fixture-only")); + assert!(!safe.contains(self.temp.path().to_str().unwrap())); + result + } + async fn action(&self, action: Action, observed: Option) -> ResultMessage { + self.receive(self.request(action, observed), |model, allow| async move { + assert!(model.is_none()); + assert!(!allow); + Ok(()) + }) + .await + } + fn running(&self) -> Option<(u32, Option)> { + let state = self.app.state::(); + let mut runtimes = state.managed_agent_processes.lock().unwrap(); + let key = agents::ManagedAgentRuntimeKey::new(&self.record.pubkey, COMMUNITY).unwrap(); + runtimes.get_mut(&key).map(|runtime| { + assert!(runtime.child.try_wait().unwrap().is_none()); + ( + runtime.child.id(), + runtime.spawn_config.runtime_configuration.clone(), + ) + }) + } + fn launched(&self, expected: &str) { + for _ in 0..100 { + if std::fs::read_to_string(self.temp.path().join("launches")) + .unwrap_or_default() + .contains(expected) + { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("fixture child did not confirm exact model and destination-local credential"); + } + async fn stop(&self) -> StopOutcome { + let event = self.ordered(self.target().sign(&self.owner).unwrap()); + let response = crate::commands::desktop_stop::receive_desktop_stop_for_app( + self.app.handle().clone(), + self.owner.public_key().to_hex(), + COMMUNITY.into(), + event.clone(), + ) + .await + .unwrap() + .unwrap(); + StopResult::read(&response, &self.owner, &event, COMMUNITY) + .unwrap() + .outcome + } +} +impl Drop for Fixture { + fn drop(&mut self) { + for (_, mut runtime) in self + .app + .state::() + .managed_agent_processes + .lock() + .unwrap() + .drain() + { + let _ = agents::terminate_process(runtime.child.id()); + 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 provisioned_destination_catalog_start_and_same_host_switch_use_shared_launch() { + let _guard = agents::lock_path_mutex(); + let mut fixture = Fixture::new(); + let catalog = fixture.action(Action::Catalog, None).await; + assert_eq!(catalog.outcome, Outcome::Ready); + assert!( + catalog + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + assert_eq!( + fixture.action(Action::Preflight, None).await.outcome, + Outcome::Ready + ); + let started = fixture.action(Action::Start, None).await; + assert_eq!(started.outcome, Outcome::Running); + assert_eq!( + started.observation.unwrap().running_configuration, + Some(fixture.named.reference()) + ); + fixture.launched("fixture-model|fixture-model"); + let old_pid = fixture.running().unwrap().0; + let status = fixture.action(Action::Status, None).await; + let mut changed = fixture.named.clone(); + changed.model = "switched-model".into(); + fixture.named = save( + &mut fixture.record, + &fixture.owner.public_key().to_hex(), + COMMUNITY, + changed, + ); + fixture.persist(); + let switched = fixture.action(Action::Restart, Some(status.id)).await; + assert_eq!(switched.outcome, Outcome::Running); + assert_ne!(fixture.running().unwrap().0, old_pid); + assert_eq!( + fixture.running().unwrap().1, + Some(fixture.named.reference()) + ); + fixture.launched("switched-model|switched-model"); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + assert!(fixture.running().is_none()); + // Deliberate same-host Stop → fresh Start consumes the new Stop fence, + // unlike a stale Restart continuation. Keep the exact switched revision. + assert_eq!( + fixture.action(Action::Preflight, None).await.outcome, + Outcome::Ready + ); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + assert_eq!( + fixture.running().unwrap().1, + Some(fixture.named.reference()) + ); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); +} + +#[tokio::test] +async fn unavailable_identity_or_runtime_excludes_catalog_and_refuses_start() { + let _guard = agents::lock_path_mutex(); + for missing in ["absent", "wrong", "runtime"] { + let mut fixture = Fixture::new(); + match missing { + "absent" => fixture.record.private_key_nsec.clear(), + "wrong" => { + fixture.record.private_key_nsec = + nostr::Keys::generate().secret_key().to_secret_hex() + } + _ => { + // Cache was warmed by catalog readiness. Revocation must still stat. + assert!(agents::resolve_command(&fixture.record.acp_command).is_some()); + std::fs::remove_file(&fixture.record.acp_command).unwrap(); + } + } + fixture.persist(); + let catalog = fixture.action(Action::Catalog, None).await; + assert_eq!(catalog.outcome, Outcome::Ready); + assert!( + !catalog + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Ineligible + ); + assert!(fixture.running().is_none()); + assert!(!fixture.temp.path().join("launches").exists()); + } +} + +#[tokio::test] +async fn revoked_key_keeps_existing_process_visible_and_restart_refuses_without_teardown() { + let _guard = agents::lock_path_mutex(); + let mut fixture = Fixture::new(); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let pid = fixture.running().unwrap().0; + fixture.record.private_key_nsec.clear(); + fixture.persist(); + let status = fixture.action(Action::Status, None).await; + assert_eq!(status.outcome, Outcome::Running); + assert_eq!( + fixture + .action(Action::Restart, Some(status.id)) + .await + .outcome, + Outcome::Ineligible + ); + assert_eq!(fixture.running().unwrap().0, pid); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + assert!(fixture.running().is_none()); +} + +#[tokio::test] +async fn key_loss_during_preflight_is_rechecked_for_start_and_catalog() { + let _guard = agents::lock_path_mutex(); + for action in [Action::Start, Action::Catalog] { + let fixture = Fixture::new(); + let result = fixture + .receive(fixture.request(action, None), |_, _| { + let mut revoked = fixture.record.clone(); + revoked.private_key_nsec.clear(); + agents::save_managed_agents(fixture.app.handle(), &[revoked]).unwrap(); + async { Ok(()) } + }) + .await; + if action == Action::Start { + assert_eq!(result.outcome, Outcome::Ineligible); + } else { + assert!( + !result + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + } + assert!(fixture.running().is_none()); + assert!(!fixture.temp.path().join("launches").exists()); + } +} + +#[tokio::test] +async fn failed_destination_preflight_never_tears_down_existing_source() { + let _guard = agents::lock_path_mutex(); + let fixture = Fixture::new(); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let pid = fixture.running().unwrap().0; + let result = fixture + .receive(fixture.request(Action::Preflight, None), |_, _| async { + Err("isolated provider unavailable".into()) + }) + .await; + assert_eq!(result.outcome, Outcome::Ineligible); + assert_eq!(fixture.running().unwrap().0, pid); +} + +// The fake child changes only its temporary fixture files when ordinary Stop +// signals it. This binds revocation to the actual teardown boundary, after both +// async and locked admission have already accepted the old key/executable. +fn on_stop(fixture: &Fixture, effect: &str) { + let script = std::fs::read_to_string(&fixture.record.acp_command).unwrap(); + let script = script + .replacen( + "#!/bin/sh\n", + &format!("#!/bin/sh\ntrap '{}' TERM\n", effect), + 1, + ) + .replace("exec /bin/sleep 20", "/bin/sleep 20 &\nwait"); + std::fs::write(&fixture.record.acp_command, script).unwrap(); +} + +fn assert_failed_without_child(fixture: &Fixture) -> ManagedAgentRecord { + assert!(fixture.running().is_none()); + let saved = agents::storage::load_agent_store(fixture.app.handle()) + .unwrap() + .into_iter() + .find(|r| r.pubkey == fixture.record.pubkey) + .unwrap(); + assert!(saved.runtime_pid.is_none()); + assert!(saved.last_stopped_at.is_some()); + assert!(saved.last_error.is_some()); + assert_eq!( + std::fs::read_to_string(fixture.temp.path().join("launches")) + .unwrap() + .lines() + .count(), + 1 + ); + saved +} + +#[tokio::test] +async fn post_stop_key_loss_or_executable_loss_persists_failed_without_restoring_credentials() { + let _guard = agents::lock_path_mutex(); + for revoke_key in [true, false] { + let fixture = Fixture::new(); + let store_path = agents::storage::managed_agents_store_path(fixture.app.handle()).unwrap(); + let revoked_path = fixture.temp.path().join("revoked.json"); + let mut revoked = fixture.record.clone(); + revoked.private_key_nsec.clear(); + std::fs::write(&revoked_path, serde_json::to_vec(&[revoked]).unwrap()).unwrap(); + let effect = if revoke_key { + format!( + "/bin/cp \"{}\" \"{}\"; exit 0", + revoked_path.display(), + store_path.display() + ) + } else { + format!("/bin/rm \"{}\"; exit 0", fixture.record.acp_command) + }; + on_stop(&fixture, &effect); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let status = fixture.action(Action::Status, None).await; + let result = fixture.action(Action::Restart, Some(status.id)).await; + assert_eq!(result.outcome, Outcome::Failed); + let saved = assert_failed_without_child(&fixture); + if revoke_key { + assert!(saved.private_key_nsec.is_empty()); + assert!(!std::fs::read_to_string(store_path) + .unwrap() + .contains(&fixture.record.private_key_nsec)); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Ineligible + ); + } else { + assert_eq!(saved.private_key_nsec, fixture.record.private_key_nsec); + } + } +} + +#[tokio::test] +async fn post_stop_expiry_persists_truthful_failed_without_second_spawn() { + let _guard = agents::lock_path_mutex(); + let fixture = Fixture::new(); + // TERM is ignored only by this bounded synthetic process; ordinary Stop + // takes its one-second grace then SIGKILL, crossing the captured deadline. + on_stop(&fixture, ""); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let mut plan = prepare_for_app( + fixture.app.handle(), + &fixture.record, + Some(&fixture.named.reference()), + &fixture.owner.public_key().to_hex(), + COMMUNITY, + ) + .unwrap(); + preflight_with( + &mut plan, + &fixture.owner.public_key().to_hex(), + COMMUNITY, + false, + |_, _| async { Ok(()) }, + ) + .await + .unwrap(); + let state = fixture.app.state::(); + let _transition = state.managed_agent_runtime_transition.lock().unwrap(); + plan.expire_at(Timestamp::now().as_secs() + 1); + let status = agents::start_pair_captured_locked( + fixture.record.pubkey.clone(), + COMMUNITY.into(), + true, + None, + None, + &plan, + false, + true, + None, + fixture.app.handle().clone(), + ) + .unwrap(); + assert_eq!( + status.lifecycle, + agents::ManagedAgentRuntimeLifecycle::Failed + ); + assert!(status.error.unwrap().contains("expired")); + let saved = assert_failed_without_child(&fixture); + assert_eq!(saved.private_key_nsec, fixture.record.private_key_nsec); +} diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 693fb302c2..96710b489a 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -242,7 +242,7 @@ pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { /// Read the raw unified store — keyed instances AND key-less definitions — /// with fail-loud parse handling. Internal seam; public readers filter. -fn load_agent_store( +pub(crate) fn load_agent_store( app: &AppHandle, ) -> Result, String> { let path = managed_agents_store_path(app)?; @@ -276,6 +276,28 @@ pub fn load_managed_agents( Ok(records) } +/// Read launch inputs without migrating keys or trusting a warm keyring cache. +/// Missing/unreadable credentials remain unavailable, but do not hide the record +/// from Status/Stop. Inline keys are the existing destination-local file fallback. +pub(crate) fn load_managed_agents_for_launch( + app: &AppHandle, +) -> Result, String> { + let mut records = load_agent_store(app)?; + records.retain(|record| !record.pubkey.is_empty()); + if let Some(store) = agent_secret_store() { + for record in &mut records { + if record.private_key_nsec.is_empty() { + record.private_key_nsec = store + .load_fresh_readonly(&agent_keyring_name(&record.pubkey)) + .ok() + .flatten() + .unwrap_or_default(); + } + } + } + Ok(records) +} + /// Load the key-less agent *definitions* (former personas) from the unified /// store. The persona compatibility shim (`load_personas`) presents these in /// the legacy shape via `to_definition_view`. @@ -396,6 +418,28 @@ pub fn save_managed_agents( write_agent_store(app, definitions, sorted) } +/// Persist only lifecycle bookkeeping from a named launch. Caller holds the +/// store lock. Never feed captured/hydrated keys into the migration save path: +/// the fresh raw store owns inline credentials, and the keyring is not written. +/// Re-read even on failure so a revoked key or removed record stays removed. +pub(crate) fn save_runtime_metadata( + app: &AppHandle, + record: &ManagedAgentRecord, +) -> Result<(), String> { + let mut current = load_agent_store(app)?; + if let Some(saved) = current.iter_mut().find(|r| r.pubkey == record.pubkey) { + saved.runtime_pid = record.runtime_pid; + saved.updated_at = record.updated_at.clone(); + saved.last_started_at = record.last_started_at.clone(); + saved.last_stopped_at = record.last_stopped_at.clone(); + saved.last_exit_code = record.last_exit_code; + saved.last_error = record.last_error.clone(); + saved.last_error_code = record.last_error_code; + } + let (definitions, instances) = current.into_iter().partition(|r| r.pubkey.is_empty()); + write_agent_store(app, definitions, instances) +} + /// Save the key-less agent *definitions*, preserving the keyed instances — /// the definition-side mirror of [`save_managed_agents`]. pub(crate) fn save_agent_definitions( diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index 43854761b5..f3ccf2be73 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -593,6 +593,25 @@ impl SecretStore { } } + /// Read one already-provisioned secret from the backend, bypassing the cache. + /// Launch admission must observe revocation and outages; never migrate or write. + pub(crate) fn load_fresh_readonly(&self, key: &str) -> Result, String> { + #[cfg(feature = "system-keyring")] + { + let Some(raw) = self.read_blob_raw()? else { + return Ok(None); + }; + let mut map: HashMap = + serde_json::from_slice(&raw).map_err(|_| "Invalid secret store".to_string())?; + Ok(map.remove(key)) + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = key; + Err("system-keyring feature disabled".into()) + } + } + /// Insert all entries from `entries` into the blob in a single mutation. /// /// Entries that already exist in the blob are overwritten; entries not diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index b95e1ccd7b..ad4e45451e 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -468,9 +468,12 @@ resume ticket before preflight; probes and Restart never receive that authority. Shared admission checks the captured runtime generation before destructive Restart, and a post-Stop Failed status must never become a Running response. No second spawn path or launch authority is allowed. Missing/edited targets fail rather than -substitute another configuration. Remote -keyless provisioning remains its existing independent fail-closed gate; a known -provisioning failure must exclude Switch before source Stop. +substitute another configuration. Remote Start uses ordinary destination-local +credentials: a matching agent key must already be independently provisioned on the +host. Catalog/preflight and execution recheck that local access; missing, unreadable +or wrong identity excludes launch choices before source Stop. No key transfer, +broker issuer, enrollment or provisioning wizard is part of this flow. Readiness +never hides an existing running process or its ordinary Stop control. Running identity comes only from the actual live process snapshot, never the next selection. A different or unknown running configuration cannot satisfy an explicit diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs index aa9615e62d..786130fa35 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -88,9 +88,7 @@ test("mounted picker expires without refresh, reports actual ref, and preserves return ( probeResult(args.request) ?? { outcome: - args.request.action === "status" - ? "running" - : "provisioning_unavailable", + args.request.action === "status" ? "running" : "ineligible", observation: { running_configuration: { id: "actual-config", @@ -155,7 +153,7 @@ test("mounted picker expires without refresh, reports actual ref, and preserves await click("Start on destination"); assert.match( document.body.textContent, - /keyless launch provisioning is unavailable/, + /exact configuration is missing, changed, or unavailable/, ); assert.equal( prepared.find((r) => r.action === "start").desktop, diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx index 4265c1c521..f94f848b15 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -59,7 +59,7 @@ function message(outcome: LifecycleOutcome) { 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."; + return "Destination cannot launch this agent. No new process was started."; case "stopped": return "Desktop reports the agent stopped."; case "ineligible": diff --git a/docs/named-runtime-configurations.md b/docs/named-runtime-configurations.md index 40c153cb37..5ce5b74dbc 100644 --- a/docs/named-runtime-configurations.md +++ b/docs/named-runtime-configurations.md @@ -60,9 +60,35 @@ 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. +before any prompt, not merely catalog membership or a successful child spawn. + +## Remote Start credentials + +Remote-initiated Start uses the same destination-local credential behavior as +ordinary local Start. The owner must independently provision the agent's matching +identity key on that host. Buzz does not transfer/distribute keys, issue a broker +session, or enroll a host through lifecycle messages. The native shared launcher +may pass that already-local key to its child just as ordinary Start does; lifecycle +requests/results never carry keys, resolved credentials, or local paths. + +Catalog and preflight require the exact owner/community/agent/host configuration +ID and revision, matching local identity, executable harness/tools, workspace and +runtime/provider prerequisites. Launch reads bypass a warm keyring cache and never +migrate a missing key into existence. Missing, unreadable or wrong identity means +ineligible, not an eligible inventory host. Catalog eligibility is revalidated +after async preflight. It is advisory and expires, never launch authority. + +Execution repeats those checks under the shared admission lock before destructive +Restart and after teardown, so a revoked key cannot be recovered from a captured +plan. Named launch success/failure saves only lifecycle metadata into a fresh raw +store; it never persists captured/hydrated credentials or migrates keys. Ownership +and generation reads likewise do not hydrate or migrate keys. The request deadline +also survives through Stop into shared spawn. Failure after Stop is Failed, not +Running or automatic resurrection. A missing key does +not hide a still-running process or block ordinary owner-authorized Stop. Move +preflights before source Stop and rechecks expiry and exact revision afterward; +same-host switches use these same fences. General host inventory is retained; +selection and running configuration are separate. No new provisioning UX is implied. ## Regression evidence @@ -70,4 +96,10 @@ transfer keys, or prove cross-host Move. 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. +fences. `runtime_configurations/tests/remote_credentials.rs` exercises the signed +receiver and shared launch with synthetic identities and bounded child processes, +including actual Stop-time credential/executable loss and post-Stop expiry. Run it +on Unix with `cargo test --manifest-path desktop/src-tauri/Cargo.toml +--no-default-features managed_agents::runtime_configurations::tests::remote_credentials +-- --test-threads=1` (seven tests; default system-keyring builds exclude the fixture). +These fixture suites do not constitute real-model or two-Desktop acceptance.