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
6 changes: 6 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,12 @@ impl AcpClient {
self.observer_context = context;
}

/// Attach a newly resolved ACP session ID without discarding the current
/// turn's channel, turn ID, or start timestamp.
pub fn set_observer_session_id(&mut self, session_id: String) {
self.observer_context.session_id = Some(session_id);
}

/// Return a clone of the observer handle, if attached.
pub(crate) fn observer_handle(&self) -> Option<ObserverHandle> {
self.observer.clone()
Expand Down
212 changes: 203 additions & 9 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,11 @@ async fn create_session_and_apply_model(
)
.await?;

// session/new is the first point at which the ACP session ID exists.
// Attach it immediately so model/mode calls and session_config_captured
// cannot be mistaken for evidence from a prior session.
agent.acp.set_observer_session_id(resp.session_id.clone());

if is_goose && agent.goose_system_prompt_supported != Some(false) {
if let Some(prompt) = combined_system_prompt.as_deref() {
match agent
Expand Down Expand Up @@ -1251,16 +1256,35 @@ async fn create_session_and_apply_model(
"relayUrl": ctx.relay_url,
}),
);

// 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)
// are safely skipped — the harness auto-approves via handle_permission_request.
if !ctx.permission_mode.is_default()
// are safely skipped — the harness rejects interactive permission requests.
let permission_mode_applied = if !ctx.permission_mode.is_default()
&& agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str())
{
apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await?;
}
apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await?
} else {
false
};

// Emit session config for desktop consumption (config bridge tier 1b).
// Emitted AFTER desired-model and permission-mode resolution so the owner
// capability manifest can distinguish requested policy from observed
// effective behavior. `capabilityManifest` is a safe projection: MCP
// commands, arguments, env vars, prompts, paths, and credentials never
// enter it.
agent.acp.observe(
"session_config_captured",
build_session_config_observation(
&resp.raw,
agent.model_overridden && switch_succeeded,
agent.desired_model.as_deref(),
switch_succeeded,
ctx,
permission_mode_applied,
),
);

Ok(resp.session_id)
}
Expand Down Expand Up @@ -1510,7 +1534,7 @@ fn patch_config_option_current_value(
///
/// Non-fatal for most errors: logs and proceeds. The agent falls back
/// to its default permission mode (`"default"`), which still works via
/// Check if the agent's `session/new` response advertises a given mode ID
/// Check whether the agent's `session/new` response advertises a given mode ID
/// in `result.modes.availableModes[].id`. Returns `false` if the modes
/// field is absent or the mode isn't listed.
fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool {
Expand All @@ -1526,15 +1550,79 @@ fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str)
.unwrap_or(false)
}

/// per-tool auto-approval in `handle_permission_request`.
fn build_session_config_observation(
session_new_result: &serde_json::Value,
model_overridden: bool,
requested_model: Option<&str>,
model_applied: bool,
ctx: &PromptContext,
permission_mode_applied: bool,
) -> serde_json::Value {
let requested_permission_mode = ctx.permission_mode.as_wire_str();
let advertised_permission_mode = session_new_result
.get("modes")
.and_then(|modes| modes.get("currentModeId"))
.and_then(serde_json::Value::as_str)
.filter(|mode| !mode.trim().is_empty());
let (effective_permission_mode, permission_mode_source) = if permission_mode_applied {
(requested_permission_mode, "runtime")
} else if let Some(current_mode) = advertised_permission_mode {
(current_mode, "runtime")
} else {
// When no runtime mode was applied, buzz-acp resolves each emitted
// permission request with allow_once (or reject_once when allow_once is
// absent). Name that harness behavior instead of claiming the runtime's
// built-in mode is known.
("perToolAutoDecision", "buzzHarness")
};
let mut tool_sources: Vec<&str> = ctx
.mcp_servers
.iter()
.map(|server| server.name.trim())
.filter(|name| !name.is_empty())
.collect();
tool_sources.sort_unstable();
tool_sources.dedup();

serde_json::json!({
"configOptions": session_new_result.get("configOptions").cloned().unwrap_or(serde_json::Value::Null),
"modes": session_new_result.get("modes").cloned().unwrap_or(serde_json::Value::Null),
"models": session_new_result.get("models").cloned().unwrap_or(serde_json::Value::Null),
"modelOverridden": model_overridden,
// Pair identity for the desktop session-config cache, which is
// keyed by (agent, relay) like the lifecycle frames.
"relayUrl": ctx.relay_url,
"capabilityManifest": {
"modelApplication": {
"requested": requested_model,
"applied": model_applied,
},
"toolSources": tool_sources
.into_iter()
.map(|name| serde_json::json!({ "name": name, "kind": "mcp" }))
.collect::<Vec<_>>(),
"permissionMode": {
"requested": requested_permission_mode,
"effective": effective_permission_mode,
"source": permission_mode_source,
},
},
})
}

/// Set the session permission mode via `session/set_config_option`.
///
/// Non-fatal for most errors: logs and proceeds. The agent falls back to its
/// default mode, and any interactive permission request is rejected by
/// `handle_permission_request`.
///
/// **Fatal exception:** if the agent process exits (e.g., goose crashes on
/// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn.
async fn apply_permission_mode(
acp: &mut AcpClient,
session_id: &str,
mode: &PermissionMode,
) -> Result<(), AcpError> {
) -> Result<bool, AcpError> {
let wire = mode.as_wire_str();
let result = tokio::time::timeout(PERMISSION_MODE_TIMEOUT, async {
acp.session_set_config_option(session_id, "mode", wire)
Expand All @@ -1548,6 +1636,7 @@ async fn apply_permission_mode(
target: "pool::permission",
"applied permission mode {wire:?} on session {session_id}"
);
return Ok(true);
}
// Transport-class errors may have corrupted the stdio stream — propagate
// so the caller can respawn the agent.
Expand Down Expand Up @@ -1578,7 +1667,7 @@ async fn apply_permission_mode(
return Err(AcpError::Timeout(PERMISSION_MODE_TIMEOUT));
}
}
Ok(())
Ok(false)
}

/// Prepend a legacy agent's standing context to a user-message body.
Expand Down Expand Up @@ -8015,6 +8104,111 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
.is_none());
}

// ── capability manifests ─────────────────────────────────────────

#[test]
fn capability_manifest_projection_exposes_names_not_mcp_secrets() {
let agent_keys = nostr::Keys::generate();
let mut ctx = make_prompt_context_impl(&agent_keys, None);
ctx.permission_mode = PermissionMode::BypassPermissions;
ctx.mcp_servers = vec![McpServer {
name: "github".to_string(),
command: "/private/bin/secret-mcp".to_string(),
args: vec!["--token".to_string(), "argument-canary".to_string()],
env: vec![crate::acp::EnvVar {
name: "TOKEN".to_string(),
value: "credential-canary".to_string(),
}],
}];

let observation = build_session_config_observation(
&json!({
"models": {
"currentModelId": "model-1",
"availableModels": [],
},
}),
false,
Some("configured-model"),
true,
&ctx,
false,
);
let manifest = &observation["capabilityManifest"];
assert_eq!(manifest["toolSources"][0]["name"], "github");
assert_eq!(
manifest["modelApplication"],
json!({
"requested": "configured-model",
"applied": true,
})
);
assert_eq!(
manifest["permissionMode"],
json!({
"requested": "bypassPermissions",
"effective": "perToolAutoDecision",
"source": "buzzHarness",
})
);

let serialized_manifest = serde_json::to_string(manifest).unwrap();
for secret in [
"/private/bin/secret-mcp",
"--token",
"argument-canary",
"TOKEN",
"credential-canary",
] {
assert!(!serialized_manifest.contains(secret));
}
}

#[test]
fn capability_manifest_reports_runtime_applied_permission_mode() {
let agent_keys = nostr::Keys::generate();
let mut ctx = make_prompt_context_impl(&agent_keys, None);
ctx.permission_mode = PermissionMode::Plan;

let observation =
build_session_config_observation(&json!({}), false, None, false, &ctx, true);
assert_eq!(
observation["capabilityManifest"]["permissionMode"],
json!({
"requested": "plan",
"effective": "plan",
"source": "runtime",
})
);
}

#[test]
fn capability_manifest_reports_runtime_advertised_default_permission_mode() {
let agent_keys = nostr::Keys::generate();
let ctx = make_prompt_context_impl(&agent_keys, None);
let observation = build_session_config_observation(
&json!({
"modes": {
"currentModeId": "plan",
"availableModes": [{"id": "plan"}],
},
}),
false,
None,
false,
&ctx,
false,
);
assert_eq!(
observation["capabilityManifest"]["permissionMode"],
json!({
"requested": "default",
"effective": "plan",
"source": "runtime",
})
);
}

// ── render_canvas_section ────────────────────────────────────────────────

#[test]
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default defineConfig({
"**/local-archive-screenshots.spec.ts",
"**/voice-settings.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/agent-capability-manifest.spec.ts",
"**/agent-error-state-screenshots.spec.ts",
"**/edit-agent.spec.ts",
"**/doctor-cta-screenshots.spec.ts",
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ pub async fn save_custom_harness(
source: HarnessSource::Custom,
definition_env: definition.env,
max_parallelism: crate::managed_agents::harness_max_parallelism(&definition.command),
capabilities: Default::default(),
})
}

Expand Down
8 changes: 5 additions & 3 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1399,12 +1399,12 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr
requires_external_cli: runtime.underlying_cli.is_some(),
underlying_cli_path,
node_required,
// Filled in by the auth-probe phase in full catalog discovery.
auth_status: AuthStatus::Unknown,
login_hint: None,
source: HarnessSource::Builtin,
definition_env: Default::default(),
max_parallelism: super::parallelism::harness_max_parallelism(runtime.id),
capabilities: runtime.into(),
},
}
}
Expand Down Expand Up @@ -1561,12 +1561,12 @@ pub fn discover_acp_runtimes_from(
requires_external_cli: false,
underlying_cli_path: None,
node_required: false,
// No auth probe for custom harnesses.
auth_status: AuthStatus::NotApplicable,
login_hint: None,
source: HarnessSource::Custom,
definition_env: def.env.clone(), // preserve for edit round-trip
definition_env: def.env.clone(),
max_parallelism: super::parallelism::harness_max_parallelism(&def.command),
capabilities: Default::default(),
});
}
}
Expand Down Expand Up @@ -1621,5 +1621,7 @@ pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
Some(runtime.avatar_url.to_string())
}

#[cfg(test)]
mod capability_manifest_tests;
#[cfg(test)]
mod tests;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#[test]
fn runtime_catalog_projects_capability_facts_for_ipc() {
let runtime = super::known_acp_runtime_exact("buzz-agent")
.expect("buzz-agent runtime metadata should exist");
let entry = super::discover_acp_runtime_phase1(runtime).entry;

assert_eq!(
entry.capabilities.supports_acp_native_config,
runtime.supports_acp_native_config
);
assert_eq!(
entry.capabilities.supports_acp_model_switching,
runtime.supports_acp_model_switching
);
assert_eq!(entry.capabilities.mcp_hooks, runtime.mcp_hooks);

let serialized = serde_json::to_value(entry).expect("catalog entry should serialize");
assert_eq!(
serialized["supports_acp_native_config"],
runtime.supports_acp_native_config
);
assert_eq!(
serialized["supports_acp_model_switching"],
runtime.supports_acp_model_switching
);
assert_eq!(serialized["mcp_hooks"], runtime.mcp_hooks);
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pub(super) fn preset_catalog_entry(
// unavailable entries (command: null in JSON, None here) still carry
// the cap — the harness cap is command-keyed, not availability-gated.
max_parallelism: crate::managed_agents::harness_max_parallelism(def.command),
capabilities: Default::default(),
}
}

Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ pub fn build_managed_agent_summary(
log_path,
respond_to: record.respond_to,
respond_to_allowlist: record.respond_to_allowlist.clone(),
credential_persistence: Some(crate::managed_agents::storage::credential_persistence_for_agent(&record.pubkey, &record.private_key_nsec)),
})
}

Expand Down
Loading