Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
740edf7
feat(acp): add isolated browser MCP bridge
gagan114662 Jul 28, 2026
bd5e2dc
Merge pull request #1 from gagan114662/fix/acp-browser-broker
gagan114662 Jul 28, 2026
9bee25b
Add local Guardian findings to agent observer (#2)
gagan114662 Jul 30, 2026
caca640
Harden local Guardian findings integration
gagan114662 Jul 30, 2026
a26727b
fix(desktop): close Guardian review blockers
gagan114662 Jul 31, 2026
6130a38
Merge pull request #3 from gagan114662/feat/numbat-sidecar
gagan114662 Jul 31, 2026
bbb4aae
Bind Numbat findings to managed agents
gagan114662 Jul 31, 2026
0bea97f
Merge pull request #4 from gagan114662/fix/numbat-agent-identity
gagan114662 Jul 31, 2026
4b17b9d
Clear desktop check debt
gagan114662 Jul 31, 2026
511d744
Merge pull request #5 from gagan114662/fix/guardian-followups
gagan114662 Jul 31, 2026
93a12ed
Complete Guardian finding control loop
gagan114662 Jul 31, 2026
946fd6d
Merge pull request #6 from gagan114662/fix/guardian-e2e-correlation
gagan114662 Jul 31, 2026
0c2e81e
feat(guardian): verify callbacks and bound retention
gagan114662 Jul 31, 2026
1f8b7a4
fix(guardian): close lifecycle retention races
gagan114662 Aug 1, 2026
ba6f1e0
Merge pull request #7 from gagan114662/feat/guardian-lifecycle
gagan114662 Aug 1, 2026
b7e983a
Add native Guardian permission policy
gagan114662 Aug 1, 2026
ccd8c0b
fix(guardian): make lockdown policy authoritative
gagan114662 Aug 1, 2026
aa72169
merge main into Guardian native policy
gagan114662 Aug 1, 2026
e56b3fe
fix(guardian): preserve integration quality gates
gagan114662 Aug 1, 2026
c284b70
style(guardian): match desktop Rust formatter
gagan114662 Aug 1, 2026
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
356 changes: 330 additions & 26 deletions crates/buzz-acp/src/acp.rs

Large diffs are not rendered by default.

145 changes: 138 additions & 7 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,16 +906,27 @@ fn handle_cancel_turn_control(
return;
};

let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel);
let status = if fired { "sent" } else { "no_active_turn" };
let Some(expected_session_id) = payload.get("sessionId").and_then(|value| value.as_str())
else {
tracing::warn!("observer cancel_turn control frame missing sessionId");
return;
};
let Some(expected_turn_id) = payload.get("turnId").and_then(|value| value.as_str()) else {
tracing::warn!("observer cancel_turn control frame missing turnId");
return;
};

let fired =
signal_expected_in_flight_task(pool, channel_id, expected_turn_id, ControlSignal::Cancel);
let status = if fired { "sent" } else { "context_mismatch" };
if let Some(observer) = observer {
observer.emit(
"control_result",
None,
&observer::ObserverContext {
channel_id: Some(channel_id.to_string()),
session_id: None,
turn_id: None,
session_id: Some(expected_session_id.to_string()),
turn_id: Some(expected_turn_id.to_string()),
started_at: None,
},
serde_json::json!({
Expand Down Expand Up @@ -2797,6 +2808,28 @@ fn signal_in_flight_task(
false
}

/// Send a control signal only when the signed observer context still names
/// the exact in-flight turn. A delayed control frame cannot cancel its successor.
fn signal_expected_in_flight_task(
pool: &mut AgentPool,
channel_id: uuid::Uuid,
expected_turn_id: &str,
mode: ControlSignal,
) -> bool {
let entry = pool
.task_map_mut()
.values_mut()
.find(|meta| meta.channel_id == Some(channel_id) && meta.turn_id == expected_turn_id);
if let Some(meta) = entry {
if let Some(tx) = meta.control_tx.take() {
tracing::info!(channel = %channel_id, turn = expected_turn_id, ?mode, "context-bound control signal sent");
let _ = tx.send(mode);
return true;
}
}
false
}

/// Attempt the non-cancelling (ACP) steer for a freshly-queued event.
///
/// Caller invariants:
Expand Down Expand Up @@ -4177,10 +4210,37 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
}

fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
let browser = std::env::var("BUZZ_ACP_BROWSER_MCP_COMMAND")
.ok()
.filter(|command| !command.is_empty())
.map(|command| {
let args = std::env::var("BUZZ_ACP_BROWSER_MCP_ARGS")
.ok()
.and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
.unwrap_or_default();
(command, args)
});
build_mcp_servers_with_browser(config, browser)
}

fn build_mcp_servers_with_browser(
config: &Config,
browser: Option<(String, Vec<String>)>,
) -> Vec<McpServer> {
let mut servers = Vec::new();
if let Some((command, args)) = browser {
servers.push(McpServer {
name: "playwright".into(),
command,
args,
env: vec![],
});
}

if config.mcp_command.is_empty() {
return vec![];
return servers;
}
vec![McpServer {
servers.push(McpServer {
name: std::path::Path::new(&config.mcp_command)
.file_stem()
.and_then(|s| s.to_str())
Expand Down Expand Up @@ -4230,7 +4290,8 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
}
env
},
}]
});
servers
}

#[cfg(test)]
Expand Down Expand Up @@ -4391,6 +4452,39 @@ mod owner_control_command_tests {
ControlSignal::Rotate
));
}

#[tokio::test]
async fn expected_turn_signal_rejects_stale_turn_without_consuming_control() {
let mut pool = AgentPool::from_slots(vec![]);
let channel_id = Uuid::new_v4();
let (control_tx, control_rx) = tokio::sync::oneshot::channel();
let abort_handle = pool.join_set.spawn(async {});
pool.task_map_mut().insert(
abort_handle.id(),
pool::TaskMeta {
agent_index: 0,
channel_id: Some(channel_id),
turn_id: "current-turn".to_string(),
recoverable_batch: None,
control_tx: Some(control_tx),
steer_tx: None,
},
);

assert!(!signal_expected_in_flight_task(
&mut pool,
channel_id,
"stale-turn",
ControlSignal::Cancel,
));
assert!(signal_expected_in_flight_task(
&mut pool,
channel_id,
"current-turn",
ControlSignal::Cancel,
));
assert_eq!(control_rx.await.unwrap(), ControlSignal::Cancel);
}
}

#[cfg(test)]
Expand Down Expand Up @@ -5184,6 +5278,43 @@ mod build_mcp_servers_tests {
"Path::new(\".\").file_stem() is None — should fall back to \"mcp\""
);
}

#[test]
fn browser_mcp_is_added_alongside_dev_mcp() {
let config = test_config();
let servers = build_mcp_servers_with_browser(
&config,
Some((
"/opt/homebrew/bin/npx".into(),
vec![
"--yes".into(),
"@playwright/mcp@0.0.78".into(),
"--browser".into(),
"chrome".into(),
"--isolated".into(),
],
)),
);

assert_eq!(servers.len(), 2);
assert_eq!(servers[0].name, "playwright");
assert_eq!(servers[0].command, "/opt/homebrew/bin/npx");
assert_eq!(servers[0].args[1], "@playwright/mcp@0.0.78");
assert_eq!(servers[1].name, "test-mcp-server");
}

#[test]
fn browser_mcp_works_without_dev_mcp() {
let mut config = test_config();
config.mcp_command.clear();
let servers = build_mcp_servers_with_browser(
&config,
Some(("npx".into(), vec!["@playwright/mcp@0.0.78".into()])),
);

assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "playwright");
}
}

#[cfg(test)]
Expand Down
63 changes: 63 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,10 @@ async fn create_session_and_apply_model(
}),
);

// Keep a harness-side enforcement fallback when an adapter cannot apply
// its native mode.
agent.acp.set_permission_mode(ctx.permission_mode.clone());

// Apply permission mode if not the agent's built-in default AND the agent
// advertises the requested mode in session/new. Agents that don't support
// the mode (e.g., goose crashes on unrecognized set_config_option values)
Expand Down Expand Up @@ -3975,6 +3979,65 @@ mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;

#[tokio::test]
async fn unsupported_native_lockdown_still_rejects_permission_requests() {
let script = r#"
IFS= read -r session_new
case "$session_new" in
*'"method":"session/new"'*) ;;
*) exit 10 ;;
esac
printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"sessionId":"fallback-session","modes":{"availableModes":[{"id":"default"}]}}}'

IFS= read -r prompt
case "$prompt" in
*'"method":"session/prompt"'*) ;;
*) exit 11 ;;
esac
printf '%s\n' '{"jsonrpc":"2.0","id":99,"method":"session/request_permission","params":{"options":[{"optionId":"allow","kind":"allow_once"},{"optionId":"reject","kind":"reject_once"}]}}'

IFS= read -r decision
case "$decision" in
*'"id":99'*'"optionId":"reject"'*) ;;
*) exit 12 ;;
esac
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"stopReason":"end_turn"}}'
"#;
let acp = AcpClient::spawn("bash", &["-c".to_string(), script.to_string()], &[], false)
.await
.expect("failed to spawn fake ACP agent");
let mut agent = OwnedAgent {
index: 0,
acp,
state: SessionState::default(),
model_capabilities: None,
desired_model: None,
model_overridden: false,
agent_name: "fake-agent".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
};
let mut ctx = make_prompt_context_no_owner();
ctx.permission_mode = PermissionMode::DontAsk;

let session_id = create_session_and_apply_model(&mut agent, &ctx, None, None, None)
.await
.expect("session creation should succeed without native dontAsk support");
assert_eq!(session_id, "fallback-session");

let stop = agent
.acp
.session_prompt_with_idle_timeout(
&session_id,
"exercise fallback",
Duration::from_secs(2),
Duration::from_secs(5),
)
.await
.expect("harness fallback should reject and let the turn complete");
assert_eq!(stop, StopReason::EndTurn);
}

// These pin the initial_message dispatch path (run_prompt_task, ~line 855):
// a legacy agent WITH a base_prompt must get [Base] prepended to the user
// message. This is the exact regression that shipped in the round-2 bug.
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default defineConfig({
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
"**/observer-feed-screenshots.spec.ts",
"**/guardian-findings.spec.ts",
"**/core-memory-screenshots.spec.ts",
"**/activity-scope-label-screenshots.spec.ts",
"**/welcome-agent-modal-screenshots.spec.ts",
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ mod media_transcode;
pub(crate) mod mesh_llm;
mod messages;
mod notifications;
pub(crate) mod numbat_findings;
mod observer_archive;
mod os_idle;
pub mod pairing;
Expand Down Expand Up @@ -89,6 +90,7 @@ pub use media_download::*;
pub use mesh_llm::*;
pub use messages::*;
pub use notifications::*;
pub use numbat_findings::*;
pub use observer_archive::*;
pub use os_idle::*;
pub use pairing::*;
Expand Down
Loading