Skip to content
Open
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
910 changes: 846 additions & 64 deletions crates/buzz-acp/src/acp.rs

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_CONFIG", default_value = "./buzz-acp.toml")]
pub config: PathBuf,

/// Desktop-owned, owner-only lifecycle ledger for ACP permission requests.
/// Managed Desktop runtimes pass a pair-scoped path. Without it, permission
/// requests are cancelled rather than represented by an unaudited card.
#[arg(long, env = "BUZZ_ACP_PERMISSION_LEDGER_PATH")]
pub permission_ledger_path: Option<PathBuf>,

#[arg(long, env = "BUZZ_ACP_DEDUP", default_value = "queue", value_enum)]
pub dedup: DedupMode,

Expand Down Expand Up @@ -566,6 +572,7 @@ pub struct Config {
pub channels_override: Option<Vec<String>>,
pub no_mention_filter: bool,
pub config_path: PathBuf,
pub permission_ledger_path: Option<PathBuf>,
pub context_message_limit: u32,
/// Maximum turns per session before proactive rotation. 0 = disabled.
pub max_turns_per_session: u32,
Expand Down Expand Up @@ -1179,6 +1186,7 @@ impl Config {
channels_override: args.channels,
no_mention_filter: args.no_mention_filter,
config_path: args.config,
permission_ledger_path: args.permission_ledger_path,
context_message_limit: args.context_message_limit,
max_turns_per_session: args.max_turns_per_session,
presence_enabled: !args.no_presence,
Expand Down Expand Up @@ -1558,6 +1566,7 @@ mod tests {
channels_override: None,
no_mention_filter: false,
config_path: PathBuf::from("./buzz-acp.toml"),
permission_ledger_path: None,
context_message_limit: 12,
max_turns_per_session: 0,
presence_enabled: true,
Expand Down
107 changes: 105 additions & 2 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod config;
mod engram_fetch;
mod filter;
mod observer;
mod permission_ledger;
mod pool;
mod pool_lifecycle;
mod prompt_framing;
Expand Down Expand Up @@ -1614,6 +1615,9 @@ fn handle_relay_observer_control_event(
Some("switch_model") => {
handle_switch_model_control(&payload, pool, observer);
}
Some("resolve_permission") => {
handle_resolve_permission_control(&payload, pool, observer);
}
Some("publish_project_owner_announcements") => {
handle_publish_project_owner_announcements_control(
&payload,
Expand All @@ -1628,6 +1632,69 @@ fn handle_relay_observer_control_event(
}
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResolvePermissionControl {
turn_id: String,
session_id: String,
request_id: serde_json::Value,
action_digest: String,
option_id: String,
}

/// Route an authenticated owner decision to one exact in-flight turn.
///
/// The owner signature and freshness window are checked before this function.
/// This handler deliberately does not use a channel ID: concurrent thread
/// sessions may share a channel. `AcpClient` performs the second, independent
/// request/session/digest/offered-option binding before it writes ACP output.
fn handle_resolve_permission_control(
payload: &serde_json::Value,
pool: &mut AgentPool,
observer: Option<&observer::ObserverHandle>,
) {
let Ok(control) = serde_json::from_value::<ResolvePermissionControl>(payload.clone()) else {
tracing::warn!("permission resolution control frame has an invalid payload");
return;
};
let resolution = acp::PermissionResolution {
turn_id: control.turn_id.clone(),
session_id: control.session_id,
request_id: control.request_id,
action_digest: control.action_digest,
option_id: control.option_id,
};
let status = match pool
.task_map_mut()
.values_mut()
.find(|meta| meta.turn_id == control.turn_id)
.and_then(|meta| meta.permission_tx.as_ref())
{
Some(tx) => match tx.try_send(resolution) {
Ok(()) => "sent",
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => "already_resolving",
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => "turn_ending",
},
None => "no_matching_turn",
};
if let Some(observer) = observer {
observer.emit(
"control_result",
None,
&observer::ObserverContext {
channel_id: None,
session_id: None,
turn_id: Some(control.turn_id),
started_at: None,
},
serde_json::json!({
"type": "resolve_permission",
"status": status,
}),
);
}
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectOwnerAnnouncementControl {
Expand Down Expand Up @@ -2510,6 +2577,17 @@ async fn tokio_main() -> Result<()> {

tracing::info!("buzz-acp starting: {}", config.summary());

// The Desktop stamps this path after all user-provided environment layers.
// Open it before spawning an ACP client so a managed owner card can never
// exist without an acknowledged host lifecycle store.
if let Some(path) = config.permission_ledger_path.clone() {
let start_nonce = std::env::var("BUZZ_MANAGED_AGENT_START_NONCE").map_err(|_| {
anyhow::anyhow!("permission lifecycle ledger requires managed start nonce")
})?;
permission_ledger::PermissionLedger::shared(path, start_nonce)
.map_err(|error| anyhow::anyhow!("permission lifecycle ledger unavailable: {error}"))?;
}

let observer = config
.relay_observer
.then(observer::ObserverHandle::in_process);
Expand Down Expand Up @@ -4522,6 +4600,8 @@ fn dispatch_pending(
// Prompt text is now built inside run_prompt_task (needs async for
// context fetching). Pass None for prompt_text; batch carries the data.
let (control_tx, control_rx) = tokio::sync::oneshot::channel::<ControlSignal>();
let (permission_tx, permission_rx) =
tokio::sync::mpsc::channel::<acp::PermissionResolution>(2);
let turn_id = Uuid::new_v4().to_string();
let task_turn_id = turn_id.clone();

Expand All @@ -4540,7 +4620,7 @@ fn dispatch_pending(
None,
ctx_clone,
result_tx,
Some(control_rx),
(Some(control_rx), Some(permission_rx)),
task_turn_id,
)
.await;
Expand All @@ -4556,6 +4636,7 @@ fn dispatch_pending(
recoverable_batch,
control_tx: Some(control_tx),
steer_tx,
permission_tx: Some(permission_tx),
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -5228,7 +5309,7 @@ fn dispatch_heartbeat(
Some(prompt_text),
ctx_clone,
result_tx,
None,
(None, None),
task_turn_id,
)
.await;
Expand All @@ -5244,6 +5325,7 @@ fn dispatch_heartbeat(
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -6070,6 +6152,7 @@ mod owner_control_command_tests {
recoverable_batch: None,
control_tx: Some(control_tx),
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -6116,6 +6199,7 @@ mod owner_control_command_tests {
recoverable_batch: None,
control_tx: Some(control_tx),
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -9154,6 +9238,7 @@ mod build_mcp_servers_tests {
channels_override: None,
no_mention_filter: false,
config_path: std::path::PathBuf::from("./buzz-acp.toml"),
permission_ledger_path: None,
context_message_limit: 12,
max_turns_per_session: 0,
presence_enabled: true,
Expand Down Expand Up @@ -9380,6 +9465,7 @@ mod error_outcome_emission_tests {
channels_override: None,
no_mention_filter: false,
config_path: std::path::PathBuf::from("./buzz-acp.toml"),
permission_ledger_path: None,
context_message_limit: 12,
max_turns_per_session: 0,
presence_enabled: true,
Expand Down Expand Up @@ -9485,6 +9571,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::from([
crate::pool::SuccessfulSteerDelivery {
event_id: steer_event_id.into(),
Expand Down Expand Up @@ -9565,6 +9652,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::from([
crate::pool::SuccessfulSteerDelivery {
event_id: "stale-event".into(),
Expand Down Expand Up @@ -9687,6 +9775,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::from([
crate::pool::SuccessfulSteerDelivery {
event_id: "stale-event".into(),
Expand Down Expand Up @@ -9756,6 +9845,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -9836,6 +9926,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -9928,6 +10019,7 @@ mod error_outcome_emission_tests {
recoverable_batch: Some(batch),
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10027,6 +10119,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10124,6 +10217,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10232,6 +10326,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10310,6 +10405,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10407,6 +10503,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10527,6 +10624,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10669,6 +10767,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10803,6 +10902,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -10958,6 +11058,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -11061,6 +11162,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down Expand Up @@ -11222,6 +11324,7 @@ mod error_outcome_emission_tests {
recoverable_batch: None,
control_tx: None,
steer_tx: None,
permission_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
Expand Down
Loading
Loading