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..b2d33051daf --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/broker_launch.rs @@ -0,0 +1,171 @@ +//! 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 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(()) + } +} + +#[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 {