Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions desktop/src-tauri/src/managed_agents/broker_launch.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
channels: Vec<String>,
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<String>,
expires_at: u64,
) -> Result<Self, String> {
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());
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 40 additions & 1 deletion desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,39 @@ pub fn spawn_agent_child(
owner_hex: Option<&str>,
replay_floor_unix: Option<u64>,
resume: Option<&super::remote_stop::ResumeTicket>,
) -> Result<crate::managed_agents::ManagedAgentProcess, String> {
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<u64>,
resume: Option<&super::remote_stop::ResumeTicket>,
broker: Option<&super::broker_launch::BrokerSession>,
) -> Result<crate::managed_agents::ManagedAgentProcess, String> {
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)?;
Expand Down Expand Up @@ -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}",
Expand Down
33 changes: 28 additions & 5 deletions desktop/src-tauri/src/managed_agents/runtime_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<ManagedAgentRuntimeStatus, String> {
let state = app.state::<AppState>();
if state.shutdown_started.load(Ordering::Acquire) {
return Err("desktop shutdown has started".into());
}
Expand Down Expand Up @@ -305,14 +327,15 @@ 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,
lazy,
owner.as_deref(),
None,
resume.as_ref(),
broker,
)?;
let now = crate::util::now_iso();
let receipt = ManagedAgentRuntimeReceipt {
Expand Down
Loading