mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
feat(desktop): introduce destination-local keyless launch boundary
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
181
desktop/src-tauri/src/managed_agents/broker_launch.rs
Normal file
181
desktop/src-tauri/src/managed_agents/broker_launch.rs
Normal file
@@ -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<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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit external integration gate, not a fabricated successful launch.
|
||||
pub(crate) fn provision(
|
||||
_scope: LaunchScope<'_>,
|
||||
_record: &ManagedAgentRecord,
|
||||
) -> Result<BrokerSession, Outcome> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
@@ -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}",
|
||||
|
||||
@@ -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<ManagedAgentRuntimeStatus, String> {
|
||||
let state = app.state::<AppState>();
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user