From 7229b0d2c7ca423c54f5345c9200d1ee478c096a Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:17:07 +0000 Subject: [PATCH] maple-agent: ACP advertises one behavior, no modes and no caller approvals The ACP surface stops modelling tool permissions. `session/new` and `session/load` advertise no modes and offer the model selector as their only config option; `session/set_mode` answers every mode id with `invalid_params` and a message that Maple has no session modes; the `mode` config option, the Interactive/Approve-all mapping onto Goose modes, the per-task mode persistence, and the loadable-mode gate on `session/list` and `session/load` are gone, so every persisted task loads whatever mode it was saved in. New ACP tasks and prompts carry no mode. The caller is no longer asked to approve tool calls: the `session/request_permission` sender for Maple's own tools, its tool-call projection (locations, edit diffs, argument preview), the one-shot option set, the fail-closed outcome mapping and the responder plumbing are removed. The project-trust chooser and elicitation stay, reworded to say that Maple runs every tool call without asking; the helper that retains outbound credit for a cancelled caller request is renamed for them. The transient stdio MCP refusal no longer cites an approval boundary. The per-account ACP configuration drops the dead `permissionMode` field; files written with it (or the older `enabled` flag) still load. With its only holder gone, the runtime's `AgentRunPermissionResponder` and the run handle's `permission_responder` field go too; the rest of the approval surface in the runtime API waits for the next layer. Co-Authored-By: Claude Fable 5.1 --- .../crates/maple-agent/src/acp/config.rs | 20 +- .../crates/maple-agent/src/acp/convert.rs | 213 +----- .../crates/maple-agent/src/acp/mod.rs | 629 ++++-------------- .../crates/maple-agent/src/acp/session.rs | 77 +-- .../crates/maple-agent/src/agent.rs | 6 - .../crates/maple-agent/src/agent/types.rs | 16 - 6 files changed, 170 insertions(+), 791 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent/src/acp/config.rs b/apps/maple-agent/crates/maple-agent/src/acp/config.rs index 97eb68b00..ecf7d3dc4 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/config.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/config.rs @@ -10,28 +10,15 @@ use std::sync::atomic::AtomicUsize; /// Upper bound on concurrent ACP connections one account may hold open. pub(super) const MAX_ACP_CONNECTIONS: usize = 8; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AgentAcpPermissionMode { - ReadOnly, - AllowAll, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AgentAcpConfig { - #[serde(default = "default_permission_mode")] - pub permission_mode: AgentAcpPermissionMode, #[serde(default)] pub allowed_project_roots: Vec, #[serde(default = "default_max_connections")] pub max_connections: usize, } -fn default_permission_mode() -> AgentAcpPermissionMode { - AgentAcpPermissionMode::ReadOnly -} - fn default_max_connections() -> usize { 8 } @@ -39,7 +26,6 @@ fn default_max_connections() -> usize { impl Default for AgentAcpConfig { fn default() -> Self { Self { - permission_mode: default_permission_mode(), allowed_project_roots: Vec::new(), max_connections: default_max_connections(), } @@ -151,10 +137,10 @@ pub(super) fn save_config_for_scope( Ok(()) } +/// Files written by older builds may still carry `enabled` or +/// `permissionMode`; serde ignores unknown fields, so they load and the next +/// save drops them. pub(super) fn normalize_config(mut config: AgentAcpConfig) -> Result { - // `allow_all` was the exploratory Desktop-owned bypass. Caller-owned ACP - // supersedes it; old files migrate to the guarded policy on their next load. - config.permission_mode = AgentAcpPermissionMode::ReadOnly; config.max_connections = config.max_connections.clamp(1, MAX_ACP_CONNECTIONS); let mut roots = Vec::new(); for root in config.allowed_project_roots { diff --git a/apps/maple-agent/crates/maple-agent/src/acp/convert.rs b/apps/maple-agent/crates/maple-agent/src/acp/convert.rs index b150e3f2d..8e0ea73bc 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/convert.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/convert.rs @@ -1,50 +1,36 @@ -//! Conversions between Maple's agent types and the ACP wire shapes: session -//! modes and config options, permission requests and decisions, and timeline -//! items rendered as session updates. +//! Conversions between Maple's agent types and the ACP wire shapes: config +//! options, the project-trust prompts, and timeline items rendered as session +//! updates. -use super::session::AcpSessionMode; use super::transport::AcpOutboundSendError; use crate::agent::{ - AgentImageUpload, AgentPermissionDecision, AgentPermissionRequest, AgentRunTerminal, - AgentRunUsage, AgentSlashCommand, AgentTimelineItem, compaction_notice_text, + AgentImageUpload, AgentRunTerminal, AgentRunUsage, AgentSlashCommand, AgentTimelineItem, + compaction_notice_text, }; use agent_client_protocol::schema::v1::{ AvailableCommand, AvailableCommandsUpdate, BooleanPropertySchema, ContentBlock, ContentChunk, - CreateElicitationRequest, Diff, ElicitationFormMode, ElicitationSchema, - ElicitationSessionScope, InitializeRequest, PermissionOption, PermissionOptionKind, - PromptResponse, RequestPermissionOutcome, SessionConfigOption, SessionConfigOptionCategory, - SessionConfigSelectOption, SessionId, SessionMode, SessionModeState, SessionUpdate, StopReason, - TextContent, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, - ToolCallUpdateFields, ToolKind, Usage, + CreateElicitationRequest, ElicitationFormMode, ElicitationSchema, ElicitationSessionScope, + InitializeRequest, PermissionOption, PermissionOptionKind, PromptResponse, + RequestPermissionOutcome, SessionConfigOption, SessionConfigOptionCategory, + SessionConfigSelectOption, SessionId, SessionUpdate, StopReason, TextContent, ToolCall, + ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, + ToolKind, Usage, }; use std::collections::HashSet; use std::path::{Path, PathBuf}; pub(super) const MAX_ACP_ERROR_CHARS: usize = 500; -/// Cap for the argument preview attached to an ACP permission request. -const MAX_ACP_PERMISSION_PREVIEW_CHARS: usize = 500; pub(super) const MAX_ACP_TOOL_TEXT_CHARS: usize = 16_000; #[derive(Default)] pub(super) struct AcpToolProjection { seen: HashSet, } -pub(super) fn acp_session_modes(current: AcpSessionMode) -> SessionModeState { - SessionModeState::new( - current.id(), - vec![ - SessionMode::new("interactive", "Interactive") - .description("Maple asks the ACP caller to approve sensitive tool calls"), - SessionMode::new("approve_all", "Approve all") - .description("Automatically approve every tool call without asking"), - ], - ) -} - +/// The model selector is the only config option: Maple has no session +/// modes, every tool call runs without asking. pub(super) fn acp_config_options( model: &str, available_models: &[String], - current_mode: AcpSessionMode, ) -> Vec { let model_options = available_models .iter() @@ -53,16 +39,6 @@ pub(super) fn acp_config_options( vec![ SessionConfigOption::select("model", "Model", model.to_string(), model_options) .category(SessionConfigOptionCategory::Model), - SessionConfigOption::select( - "mode", - "Mode", - current_mode.id(), - vec![ - SessionConfigSelectOption::new("interactive", "Interactive"), - SessionConfigSelectOption::new("approve_all", "Approve all"), - ], - ) - .category(SessionConfigOptionCategory::Mode), ] } @@ -70,13 +46,12 @@ pub(super) fn acp_session_config_options( model: &str, available_models: &[String], message_count: usize, - current_mode: AcpSessionMode, ) -> Vec { if message_count == 0 { - acp_config_options(model, available_models, current_mode) + acp_config_options(model, available_models) } else { let locked_models = [model.to_string()]; - acp_config_options(model, &locked_models, current_mode) + acp_config_options(model, &locked_models) } } @@ -185,141 +160,6 @@ fn image_file_extension(mime_type: &str) -> &'static str { } } -pub(super) fn acp_permission_tool_call( - request: &AgentPermissionRequest, - item: &AgentTimelineItem, -) -> ToolCall { - let title = item - .title - .clone() - .unwrap_or_else(|| format!("Approve {}", request.tool_name)); - let mut tool_call = ToolCall::new(request.request_id.clone(), title) - .kind(acp_tool_kind(&request.tool_name)) - .status(ToolCallStatus::Pending) - .raw_input(serde_json::Value::Object(request.arguments.clone())); - let locations = acp_permission_locations(request); - if !locations.is_empty() { - tool_call = tool_call.locations(locations); - } - if let Some(prompt) = request.prompt.as_ref().filter(|prompt| !prompt.is_empty()) { - tool_call = tool_call.content(vec![ToolCallContent::from(ContentBlock::Text( - TextContent::new(prompt.clone()), - ))]); - return tool_call; - } - match edit_diff_contents(request) { - Some(diffs) => tool_call.content(diffs), - None => { - let preview = bounded_permission_arguments(request); - if !preview.is_empty() { - tool_call = tool_call.content(vec![ToolCallContent::from(ContentBlock::Text( - TextContent::new(preview), - ))]); - } - tool_call - } - } -} - -/// File locations for an approval card, so the caller can link to what is -/// about to change. Only absolute paths are reported. -fn acp_permission_locations(request: &AgentPermissionRequest) -> Vec { - let arguments = serde_json::Value::Object(request.arguments.clone()); - let Some(path) = tool_path(&arguments).filter(|path| Path::new(path).is_absolute()) else { - return Vec::new(); - }; - // `offset` names a one-based line for read; `line` covers tools that - // say it directly. - let line = ["offset", "line"].iter().find_map(|key| { - arguments - .get(*key) - .and_then(serde_json::Value::as_u64) - .filter(|line| *line > 0 && *line <= u32::MAX as u64) - .map(|line| line as u32) - }); - vec![ToolCallLocation::new(PathBuf::from(path)).line(line)] -} - -/// Edit and write approvals rendered as diffs, the shape ACP clients show -/// inline. `None` when the call is not a bounded file modification. -fn edit_diff_contents(request: &AgentPermissionRequest) -> Option> { - let bare_name = request - .tool_name - .rsplit("__") - .next() - .unwrap_or(&request.tool_name); - let arguments = serde_json::Value::Object(request.arguments.clone()); - let path = tool_path(&arguments)?; - let diff = |old: Option<&str>, new: &str| { - ToolCallContent::Diff( - Diff::new( - PathBuf::from(path), - bounded_chars(new, MAX_ACP_PERMISSION_PREVIEW_CHARS), - ) - .old_text(old.map(|old| bounded_chars(old, MAX_ACP_PERMISSION_PREVIEW_CHARS))), - ) - }; - match bare_name { - "edit" => { - let edits = arguments.get("edits")?.as_array()?; - let contents = edits - .iter() - .filter_map(|edit| { - let new = edit.get("newText").and_then(serde_json::Value::as_str)?; - let old = edit.get("oldText").and_then(serde_json::Value::as_str); - Some(diff(old, new)) - }) - .collect::>(); - (!contents.is_empty()).then_some(contents) - } - "write" => { - let new = arguments - .get("content") - .and_then(serde_json::Value::as_str)?; - Some(vec![diff(None, new)]) - } - _ => None, - } -} - -/// A bounded, human-readable view of a tool call's arguments. ACP clients -/// render an approval card from `content`; without it a card such as edit -/// shows a bare title even though `rawInput` carries the change. -fn bounded_permission_arguments(request: &AgentPermissionRequest) -> String { - let compact = serde_json::Value::Object(request.arguments.clone()).to_string(); - if compact == "{}" { - return String::new(); - } - let bounded = bounded_chars(&compact, MAX_ACP_PERMISSION_PREVIEW_CHARS); - if bounded.chars().count() < compact.chars().count() { - format!("{bounded}…") - } else { - bounded - } -} - -pub(super) fn acp_tool_kind(tool_name: &str) -> ToolKind { - match tool_name.rsplit("__").next().unwrap_or(tool_name) { - "shell" | "computer" => ToolKind::Execute, - "read" | "read_image" => ToolKind::Read, - "edit" | "write" | "text_editor" => ToolKind::Edit, - "search" | "web_search" => ToolKind::Search, - "open_url" => ToolKind::Fetch, - _ => ToolKind::Other, - } -} - -pub(super) fn acp_permission_options() -> Vec { - vec![ - PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce), - PermissionOption::new( - "reject_once", - "Reject once", - PermissionOptionKind::RejectOnce, - ), - ] -} - pub(super) fn client_supports_form_elicitation(request: &InitializeRequest) -> bool { request .client_capabilities @@ -350,7 +190,7 @@ pub(super) fn project_trust_elicitation_request( CreateElicitationRequest::new( ElicitationFormMode::new(ElicitationSessionScope::new(session_id), schema), format!( - "Trust project '{}'? Normal tool permissions still apply.", + "Trust project '{}'? Maple runs every tool call without asking.", project_root.display() ), ) @@ -399,27 +239,6 @@ pub(super) fn project_trust_permission_decision( } } -pub(super) fn acp_permission_decision( - outcome: &RequestPermissionOutcome, -) -> Result { - match outcome { - RequestPermissionOutcome::Cancelled => Ok(AgentPermissionDecision::Cancel), - RequestPermissionOutcome::Selected(selected) - if selected.option_id.0.as_ref() == "allow_once" => - { - Ok(AgentPermissionDecision::AllowOnce) - } - RequestPermissionOutcome::Selected(selected) - if selected.option_id.0.as_ref() == "reject_once" => - { - Ok(AgentPermissionDecision::DenyOnce) - } - RequestPermissionOutcome::Selected(_) => { - Err("ACP client selected an unknown Maple permission option".to_string()) - } - _ => Err("ACP client returned an unsupported Maple permission outcome".to_string()), - } -} pub(super) const COMPACTION_COMPLETED_NOTICE: &str = "Compaction completed.\n"; pub(super) fn timeline_update( diff --git a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs index 4eb2fe2e0..abeb8959a 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs @@ -1,8 +1,6 @@ use crate::agent::{ AGENT_TOOL_CONTEXT_INACTIVE_ERROR, AgentCreateSessionRequest, AgentHostEventPolicy, - AgentPermissionDecision, AgentPermissionRequest, AgentRunEvent, AgentRunPermissionResponder, - AgentRunTerminal, AgentRuntimeHandle, AgentSendMessageRequest, AgentTaskState, - AgentTimelineItem, + AgentRunEvent, AgentRunTerminal, AgentRuntimeHandle, AgentSendMessageRequest, AgentTaskState, }; mod config; mod convert; @@ -10,24 +8,22 @@ mod handler; mod session; mod transport; -pub use config::{AgentAcpConfig, AgentAcpPermissionMode, load_acp_config}; +pub use config::{AgentAcpConfig, load_acp_config}; use config::{AgentAcpStats, normalize_config}; use convert::{ AcpToolProjection, COMPACTION_COMPLETED_NOTICE, MAX_ACP_ERROR_CHARS, acp_available_commands, - acp_config_options, acp_permission_decision, acp_permission_options, acp_permission_tool_call, - acp_session_config_options, acp_session_modes, acp_usage, bounded_chars, event_error_text, + acp_config_options, acp_session_config_options, acp_usage, bounded_chars, event_error_text, internal_acp_error, outbound_error, parse_slash_command, project_trust_elicitation_request, project_trust_permission_decision, project_trust_permission_options, prompt_images, prompt_result_from_terminal, prompt_text, subagent_tool_update, timeline_update, }; use handler::{AcpCallerSessionFields, MapleAcpHandler}; use session::{ - ALLOWED_BRIDGE_ENV, AcpConnectionContext, AcpPermissionResolution, AcpProjectTrustResolution, - AcpPromptState, AcpSession, AcpSessionMode, AcpSessionOperation, SharedRuntimeStart, - UnpublishedAcpSession, bridge_tool_context_spec, canonical_session_id, - canonical_session_id_text, close_registration_may_be_released, ensure_acp_session_is_loadable, - ensure_allowed_project_root, filter_bridge_environment, has_buzz_credentials, - is_acp_loadable_session_mode, prepare_session_mcp, + ALLOWED_BRIDGE_ENV, AcpConnectionContext, AcpProjectTrustResolution, AcpPromptState, + AcpSession, AcpSessionOperation, SharedRuntimeStart, UnpublishedAcpSession, + bridge_tool_context_spec, canonical_session_id, canonical_session_id_text, + close_registration_may_be_released, ensure_allowed_project_root, filter_bridge_environment, + find_acp_session, has_buzz_credentials, prepare_session_mcp, }; use transport::{ AcpOutboundReservation, AcpOutboundSendError, AcpOutboundTracker, BoundedLineReader, @@ -189,7 +185,6 @@ impl AcpConnectionContext { let (environment, transient_mcp_servers) = prepare_session_mcp(&bridge_environment, &request.mcp_servers)?; let tool_context = bridge_tool_context_spec(&environment).map_err(internal_acp_error)?; - let mode = AcpSessionMode::default(); let created = self .agent .create_session_with_surface_context( @@ -202,7 +197,7 @@ impl AcpConnectionContext { ), model: Some(model.clone()), context_limit: None, - mode: Some(mode.maple_mode().to_string()), + mode: None, mcp_server_names: None, system_prompt: caller.system_prompt, }), @@ -211,7 +206,7 @@ impl AcpConnectionContext { self.lifetime.child_token(), // The ACP caller is the only interactive surface for this task. // Persisted history remains loadable in Maple Desktop, but live - // permission cards must never create a second approval broker. + // question cards must never open a second interactive surface. AgentHostEventPolicy::Suppress, ) .await @@ -254,7 +249,6 @@ impl AcpConnectionContext { prompted: false, project_root: project_root.clone(), project_trust_decision: project_trust.decision, - mode, }, ); operations.insert(session_id.clone(), AcpSessionOperation::new(&self.lifetime)); @@ -271,12 +265,7 @@ impl AcpConnectionContext { self.send_available_commands(cx, &session_id, &command_root) .await; Ok(NewSessionResponse::new(session_id) - .modes(acp_session_modes(AcpSessionMode::default())) - .config_options(acp_config_options( - &model, - &available_models, - AcpSessionMode::default(), - ))) + .config_options(acp_config_options(&model, &available_models))) } async fn retire_session(&self, session_id: &str) { @@ -336,20 +325,6 @@ impl AcpConnectionContext { limit } - /// Save a caller-selected mode to the task row so a later connection - /// loads it. Runtime policy follows on the next prompt regardless, - /// because every prompt carries the live mode. - async fn persist_session_mode( - &self, - session_id: &str, - mode: AcpSessionMode, - ) -> Result<(), agent_client_protocol::Error> { - self.agent - .persist_session_permission_mode(session_id, mode.maple_mode()) - .await - .map_err(internal_acp_error) - } - /// Soft-delete a task: archive it so it disappears from session lists /// but stays recoverable in Maple Desktop. An owned live session is /// retired first; a running turn refuses the delete. @@ -429,13 +404,9 @@ impl AcpConnectionContext { .list_sessions(Some(project_root_text.clone())) .await .map_err(internal_acp_error)?; - let persisted = ensure_acp_session_is_loadable(&persisted_sessions, &session_id) + let persisted = find_acp_session(&persisted_sessions, &session_id) .map_err(|error| agent_client_protocol::Error::invalid_request().data(error))?; let persisted_model = persisted.model.clone(); - let session_mode = match persisted.mode.as_str() { - "auto" => AcpSessionMode::ApproveAll, - _ => AcpSessionMode::Interactive, - }; let available_models = self.available_models().await?; if let Some(model) = persisted_model.as_ref() && !available_models.iter().any(|available| available == model) @@ -545,7 +516,6 @@ impl AcpConnectionContext { prompted: false, project_root, project_trust_decision: project_trust.decision, - mode: session_mode, }, ); drop(sessions); @@ -570,14 +540,13 @@ impl AcpConnectionContext { drop(operation_guard); self.send_available_commands(cx, &session_id, &command_root) .await; - Ok(LoadSessionResponse::new() - .modes(acp_session_modes(session_mode)) - .config_options(acp_session_config_options( + Ok( + LoadSessionResponse::new().config_options(acp_session_config_options( &model, &available_models, message_count, - session_mode, - ))) + )), + ) } async fn list_sessions( @@ -611,12 +580,11 @@ impl AcpConnectionContext { let visible = sessions .into_iter() .filter(|session| { - is_acp_loadable_session_mode(&session.mode) - && ensure_allowed_project_root( - Path::new(&session.project_root), - &config.allowed_project_roots, - ) - .is_ok() + ensure_allowed_project_root( + Path::new(&session.project_root), + &config.allowed_project_roots, + ) + .is_ok() }) .collect::>(); let start = request @@ -764,13 +732,12 @@ impl AcpConnectionContext { .data("ACP session is closing"), ); } - let (response, pending_mode) = { + let response = { let mut sessions = self.sessions.lock().await; let session = sessions.get_mut(&session_id).ok_or_else(|| { agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) .data("ACP session is not owned by this connection") })?; - let mut pending_mode: Option = None; let selected_value = request.value.as_value_id().ok_or_else(|| { agent_client_protocol::Error::invalid_params() .data("Maple ACP configuration options require a select value") @@ -793,31 +760,19 @@ impl AcpConnectionContext { session.model = model.to_string(); session.context_limit = None; } - "mode" => { - let mode = - AcpSessionMode::parse(selected_value.0.as_ref()).ok_or_else(|| { - agent_client_protocol::Error::invalid_params() - .data("Unknown Maple ACP permission mode") - })?; - session.mode = mode; - pending_mode = Some(mode); - } _ => { return Err(agent_client_protocol::Error::invalid_params() .data("Unknown Maple ACP configuration option")); } } - let response = SetSessionConfigOptionResponse::new(session.config_options()); - (response, pending_mode) + SetSessionConfigOptionResponse::new(session.config_options()) }; - // Persist outside the sessions lock; the write only decides what a - // later connection loads, while every prompt carries the live mode. - if let Some(mode) = pending_mode { - self.persist_session_mode(&session_id, mode).await?; - } Ok(response) } + /// Maple advertises no session modes, so no mode id can be selected. + /// The route stays so a client that still sends one gets a clear + /// `invalid_params` answer instead of a method-not-found error. async fn set_mode( &self, request: SetSessionModeRequest, @@ -843,15 +798,10 @@ impl AcpConnectionContext { .data("ACP session is not available on this connection"), ); } - let mode = AcpSessionMode::parse(request.mode_id.0.as_ref()).ok_or_else(|| { - agent_client_protocol::Error::invalid_params().data("Unknown Maple ACP permission mode") - })?; - self.persist_session_mode(&session_id, mode).await?; - let mut sessions = self.sessions.lock().await; - if let Some(session) = sessions.get_mut(&session_id) { - session.mode = mode; - } - Ok(SetSessionModeResponse::new()) + Err(agent_client_protocol::Error::invalid_params().data(format!( + "Maple ACP has no session modes ('{}' cannot be selected): every tool call runs without asking", + request.mode_id.0 + ))) } async fn begin_prompt( @@ -1071,7 +1021,7 @@ impl AcpConnectionContext { response = &mut response_future => Some(response), }; let Some(response) = response else { - retain_cancelled_permission_request(response_future, reservation); + retain_cancelled_caller_request(response_future, reservation); return Ok(None); }; drop(reservation); @@ -1116,7 +1066,7 @@ impl AcpConnectionContext { .status(ToolCallStatus::Pending) .content(vec![ToolCallContent::from(ContentBlock::Text( TextContent::new(format!( - "Trusting '{}' allows Maple to use project-provided guidance, including agent skills. These instructions can influence how agents work and use tools. Normal tool permissions still apply.", + "Trusting '{}' allows Maple to use project-provided guidance, including agent skills. These instructions can influence how agents work and use tools, and Maple runs every tool call without asking.", project_root.display() )), ))]); @@ -1144,7 +1094,7 @@ impl AcpConnectionContext { response = &mut response_future => Some(response), }; let Some(response) = response else { - retain_cancelled_permission_request(response_future, reservation); + retain_cancelled_caller_request(response_future, reservation); return Ok(None); }; drop(reservation); @@ -1248,81 +1198,6 @@ impl AcpConnectionContext { Ok(AcpProjectTrustResolution::Continue) } - async fn request_permission_from_caller( - &self, - cx: &ConnectionTo, - session_id: SessionId, - request: AgentPermissionRequest, - item: &AgentTimelineItem, - responder: &AgentRunPermissionResponder, - cancellation: &CancellationToken, - ) -> Result { - let tool_call = acp_permission_tool_call(&request, item); - let permission_request = - RequestPermissionRequest::new(session_id, tool_call.into(), acp_permission_options()); - let encoded_bytes = serde_json::to_vec(&permission_request) - .map_err(|error| { - AcpOutboundSendError::Transport(internal_acp_error(format!( - "Failed to encode Maple ACP permission request: {error}" - ))) - })? - .len(); - // Permission requests use the same global event/byte budget as streamed - // notifications. Hold the reservation until the caller responds so a - // slow client cannot accumulate unbounded JSON-RPC request frames. - let reservation = self.outbound.reserve(encoded_bytes, cancellation).await?; - if cancellation.is_cancelled() { - cancel_maple_permission(responder, &request.request_id).await; - return Ok(AcpPermissionResolution::Cancelled); - } - let sent_request = cx.send_request(permission_request); - let mut response_future = Box::pin(sent_request.block_task()); - let response = tokio::select! { - biased; - _ = cancellation.cancelled() => None, - response = &mut response_future => Some(response), - }; - let Some(response) = response else { - // ACP v1 has no stable request-cancellation primitive. Stop Maple - // immediately, but keep consuming the already-sent JSON-RPC request - // and retain its outbound credits until the client replies or the - // connection closes. Otherwise a cancel-and-never-reply client can - // accumulate unbounded SDK correlation entries outside our limit. - cancel_maple_permission(responder, &request.request_id).await; - retain_cancelled_permission_request(response_future, reservation); - return Ok(AcpPermissionResolution::Cancelled); - }; - drop(reservation); - - let (decision, resolution) = match response { - Ok(response) => match acp_permission_decision(&response.outcome) { - Ok(AgentPermissionDecision::Cancel) => ( - AgentPermissionDecision::Cancel, - AcpPermissionResolution::Cancelled, - ), - Ok(decision) => (decision, AcpPermissionResolution::Continue), - Err(error) => { - cancel_maple_permission(responder, &request.request_id).await; - return Err(AcpOutboundSendError::Transport(internal_acp_error(error))); - } - }, - Err(error) => { - cancel_maple_permission(responder, &request.request_id).await; - return Err(AcpOutboundSendError::Transport(error)); - } - }; - - if let Err(error) = responder.respond(request.request_id, decision).await { - if cancellation.is_cancelled() { - return Ok(AcpPermissionResolution::Cancelled); - } - return Err(AcpOutboundSendError::Transport(internal_acp_error( - format!("Failed to resolve Maple permission request: {error}"), - ))); - } - Ok(resolution) - } - #[allow(clippy::too_many_arguments)] async fn prompt( self: &Arc, @@ -1350,7 +1225,7 @@ impl AcpConnectionContext { return Err(error); } } - let (tool_context_access, model, session_mode, project_root) = { + let (tool_context_access, model, project_root) = { let sessions = self.sessions.lock().await; match sessions.get(&session_id) { Some(session) => ( @@ -1360,7 +1235,6 @@ impl AcpConnectionContext { .expect("a runnable ACP session must own a lease") .access(), session.model.clone(), - session.mode, session.project_root.clone(), ), None => { @@ -1433,7 +1307,7 @@ impl AcpConnectionContext { text: prompt, model: Some(model.clone()), context_limit: None, - mode: Some(session_mode.maple_mode().to_string()), + mode: None, vision_capable, steer: false, queue_id: None, @@ -1482,13 +1356,6 @@ impl AcpConnectionContext { return Err(agent_client_protocol::Error::internal_error() .data("Maple did not create an ACP cancellation capability for this run")); }; - let Some(permission_responder) = run.permission_responder else { - prompt_lifetime.cancel(); - let _ = run_cancellation.cancel().await; - self.prompt_states.lock().await.remove(&session_id); - return Err(agent_client_protocol::Error::internal_error() - .data("Maple did not create an ACP permission responder for this run")); - }; let prompt_registered = { let mut states = self.prompt_states.lock().await; match states.get_mut(&session_id) { @@ -1647,54 +1514,9 @@ impl AcpConnectionContext { } } } - Some(AgentRunEvent::PermissionRequested { - request: permission, - item, - }) => { - match self - .request_permission_from_caller( - cx, - protocol_session_id.clone(), - permission, - &item, - &permission_responder, - &prompt_lifetime, - ) - .await - { - Ok(AcpPermissionResolution::Continue) => {} - Ok(AcpPermissionResolution::Cancelled) => { - cancel_after_result = true; - break Ok(PromptResponse::new(StopReason::Cancelled)); - } - Err(AcpOutboundSendError::UpdateTooLarge) => { - cancel_after_result = true; - let _ = run_cancellation.cancel().await; - match self - .send_final_agent_message( - cx, - protocol_session_id.clone(), - "Maple stopped this turn because one ACP permission request exceeded the 4 MiB transport limit.", - &self.lifetime, - ) - .await - { - Ok(()) | Err(AcpOutboundSendError::UpdateTooLarge) => { - break Ok(PromptResponse::new(StopReason::EndTurn)); - } - Err(AcpOutboundSendError::Cancelled) => { - break Ok(PromptResponse::new(StopReason::Cancelled)); - } - Err(AcpOutboundSendError::Transport(error)) => break Err(error), - } - } - Err(AcpOutboundSendError::Cancelled) => { - cancel_after_result = true; - break Ok(PromptResponse::new(StopReason::Cancelled)); - } - Err(AcpOutboundSendError::Transport(error)) => break Err(error), - } - } + // The runtime no longer asks for tool permissions; the + // variant stays declared until the API drops it. + Some(AgentRunEvent::PermissionRequested { .. }) => {} Some(AgentRunEvent::Error(item)) => { if let Some(message) = event_error_text(&item) { match self @@ -2085,18 +1907,7 @@ impl AcpConnectionContext { } } -async fn cancel_maple_permission(responder: &AgentRunPermissionResponder, request_id: &str) { - if let Err(error) = responder - .respond(request_id.to_string(), AgentPermissionDecision::Cancel) - .await - { - log::debug!( - "Maple ACP permission request {request_id} was already resolved while failing closed: {error}" - ); - } -} - -fn retain_cancelled_permission_request(response: F, reservation: AcpOutboundReservation) +fn retain_cancelled_caller_request(response: F, reservation: AcpOutboundReservation) where F: std::future::Future + Send + 'static, T: Send + 'static, @@ -2128,7 +1939,7 @@ mod tests { use super::session::completed_runtime_start; use super::transport::is_session_update_line; use super::*; - use crate::agent::{AgentRunUsage, AgentSessionSummary}; + use crate::agent::{AgentRunUsage, AgentSessionSummary, AgentTimelineItem}; use agent_client_protocol::schema::v1::{ ClientCapabilities, ElicitationCapabilities, ElicitationFormCapabilities, McpServerStdio, SelectedPermissionOutcome, @@ -2259,11 +2070,12 @@ mod tests { /// The stdio contract an editor depends on before it can do anything: /// `initialize` answers, `session/new` returns the mode list AND the model /// list, and the task it persists is loadable by a later connection. - /// Regression shape: any mismatch between the mode ACP maps onto and the - /// runtime's external-surface gates turns `session/new` (or `session/load`) - /// into an error and the editor loses the model list entirely. + /// `session/new` and `session/load` advertise no modes and offer the + /// model selector only; a persisted task loads whatever mode it was saved + /// in. Regression shape: a gate on the persisted mode turned `session/load` + /// into an error and the editor lost the model list entirely. #[tokio::test] - async fn stdio_surface_returns_modes_models_and_loads_persisted_sessions() { + async fn session_new_and_load_advertise_no_modes() { let agent = started_agent_runtime("acp-stdio").await; let project_root = agent.project_root.to_string_lossy().into_owned(); @@ -2299,17 +2111,18 @@ mod tests { .expect("session/new must return a session id") .to_string(); assert!(!session_id.is_empty()); - assert_eq!( - new_session["result"]["modes"]["currentModeId"], "interactive", - "a fresh session must start interactive: {new_session}" + assert!( + new_session["result"]["modes"].is_null(), + "a session advertises no modes: {new_session}" + ); + assert!( + new_session["result"]["configOptions"] + .as_array() + .expect("configOptions must be a list") + .iter() + .all(|option| option["id"] != "mode"), + "no mode config option is offered: {new_session}" ); - let mode_ids: Vec<&str> = new_session["result"]["modes"]["availableModes"] - .as_array() - .expect("modes must be a list") - .iter() - .map(|mode| mode["id"].as_str().expect("mode ids are strings")) - .collect(); - assert_eq!(mode_ids, vec!["interactive", "approve_all"]); let model_option = new_session["result"]["configOptions"] .as_array() @@ -2334,37 +2147,14 @@ mod tests { "the current model must come from the offered list: {current:?} in {offered:?}" ); - let id = client - .request( - "session/set_mode", - json!({"sessionId": session_id, "modeId": "approve_all"}), - ) - .await; - let switched = client.response(id).await; - assert!( - switched.get("error").is_none(), - "approve_all is a mode: {switched}" - ); - let id = client - .request( - "session/set_mode", - json!({"sessionId": session_id, "modeId": "bogus"}), - ) - .await; - let rejected = client.response(id).await; - assert!( - rejected.get("error").is_some(), - "unknown modes must be rejected: {rejected}" - ); - // Client EOF must end the serve task and release the session lease. client.shutdown().await; finish_acp_stdio_serve(serving).await; // A persisted task must be loadable by a later connection. The ACP // task above stays hidden from listing until its first prompt, so load - // a desktop task instead. Whatever mode a caller named, every task - // runs with every tool call allowed, which is what the load reports. + // a desktop task instead, one saved in a mode older builds used to + // refuse. Every task runs with every tool call allowed. let created = agent .handle .create_session(Some(crate::agent::AgentCreateSessionRequest { @@ -2399,14 +2189,66 @@ mod tests { "session/load failed: {}", loaded["error"] ); - assert_eq!( - loaded["result"]["modes"]["currentModeId"], "approve_all", - "a persisted task loads with every tool call allowed: {loaded}" + assert!( + loaded["result"]["modes"].is_null(), + "a loaded task advertises no modes: {loaded}" ); client.shutdown().await; finish_acp_stdio_serve(serving).await; } + /// `session/set_mode` is answered with `invalid_params` for every mode id, + /// including the ids older builds advertised. + #[tokio::test] + async fn set_mode_is_rejected() { + let agent = started_agent_runtime("acp-set-mode").await; + let project_root = agent.project_root.to_string_lossy().into_owned(); + + let (mut client, serving) = spawn_acp_stdio_serve(agent.handle.clone()).await; + let id = client + .request( + "initialize", + json!({"protocolVersion": 2, "clientCapabilities": {}}), + ) + .await; + client.response(id).await; + let id = client + .request( + "session/new", + json!({"cwd": project_root, "mcpServers": []}), + ) + .await; + let new_session = client.response(id).await; + let session_id = new_session["result"]["sessionId"] + .as_str() + .expect("session/new must return a session id") + .to_string(); + + for mode_id in ["approve_all", "interactive", "bogus"] { + let id = client + .request( + "session/set_mode", + json!({"sessionId": session_id, "modeId": mode_id}), + ) + .await; + let rejected = client.response(id).await; + assert_eq!( + rejected["error"]["code"], -32602, + "set_mode({mode_id}) must fail with invalid_params: {rejected}" + ); + assert!( + rejected["error"]["data"] + .as_str() + .unwrap_or_default() + .contains("no session modes"), + "the error explains that Maple has no modes: {rejected}" + ); + } + + client.shutdown().await; + finish_acp_stdio_serve(serving).await; + } + /// The stdio contract for image prompts: an editor may attach an image /// no matter what the catalog says about the session model. Vision models /// get the image embedded, everyone else gets a read_image reference, and @@ -2542,10 +2384,10 @@ mod tests { } #[test] - fn default_config_is_caller_mediated() { + fn default_config_allows_eight_connections() { let config = AgentAcpConfig::default(); - assert_eq!(config.permission_mode, AgentAcpPermissionMode::ReadOnly); assert_eq!(config.max_connections, 8); + assert!(config.allowed_project_roots.is_empty()); } #[test] @@ -2590,46 +2432,21 @@ mod tests { } } + /// Every persisted task loads, whatever mode it was saved in; only a + /// missing task is refused. #[test] - fn session_list_loadability_accepts_read_only_and_approve_all_tasks() { + fn session_load_preflight_accepts_every_saved_mode_and_rejects_missing_tasks() { let sessions = [ - session_summary_with_mode("read-only", "smart_approve"), - session_summary_with_mode("approve-all", "auto"), - session_summary_with_mode("approval", "approve"), + session_summary_with_mode("ask-first", "smart_approve"), + session_summary_with_mode("allow-all", "auto"), session_summary_with_mode("chat", "chat"), - session_summary_with_mode("unknown", "future_mode"), - ]; - - let visible = sessions - .iter() - .filter(|session| is_acp_loadable_session_mode(&session.mode)) - .map(|session| session.id.as_str()) - .collect::>(); - - assert_eq!(visible, vec!["read-only", "approve-all", "approval"]); - } - - #[test] - fn session_load_preflight_rejects_unknown_and_missing_tasks() { - let sessions = [ - session_summary_with_mode("read-only", "smart_approve"), - session_summary_with_mode("approve-all", "auto"), ]; - assert_eq!( - ensure_acp_session_is_loadable(&sessions, "read-only") - .unwrap() - .id, - "read-only" - ); - assert_eq!( - ensure_acp_session_is_loadable(&sessions, "approve-all") - .unwrap() - .id, - "approve-all" - ); + for id in ["ask-first", "allow-all", "chat"] { + assert_eq!(find_acp_session(&sessions, id).unwrap().id, id); + } assert!( - ensure_acp_session_is_loadable(&sessions, "missing") + find_acp_session(&sessions, "missing") .unwrap_err() .contains("does not exist") ); @@ -2805,32 +2622,28 @@ mod tests { assert_eq!(encoded["cachedWriteTokens"], 1); } + /// The model selector is the only config option, and it locks to the + /// persisted model after the first message. #[test] fn model_selector_locks_to_the_persisted_model_after_first_message() { let models = vec!["model-a".to_string(), "model-b".to_string()]; - let fresh = serde_json::to_value(acp_session_config_options( - "model-b", - &models, - 0, - AcpSessionMode::Interactive, - )) - .expect("fresh model options should serialize"); - let locked = serde_json::to_value(acp_session_config_options( - "model-b", - &models, - 1, - AcpSessionMode::ApproveAll, - )) - .expect("locked model options should serialize"); + let fresh = serde_json::to_value(acp_session_config_options("model-b", &models, 0)) + .expect("fresh model options should serialize"); + let locked = serde_json::to_value(acp_session_config_options("model-b", &models, 1)) + .expect("locked model options should serialize"); + assert_eq!( + fresh.as_array().unwrap().len(), + 1, + "no mode option: {fresh}" + ); + assert_eq!(fresh[0]["id"], "model"); assert_eq!(fresh[0]["currentValue"], "model-b"); assert_eq!(fresh[0]["options"].as_array().unwrap().len(), 2); - assert_eq!(fresh[1]["currentValue"], "interactive"); + assert_eq!(locked.as_array().unwrap().len(), 1); assert_eq!(locked[0]["currentValue"], "model-b"); assert_eq!(locked[0]["options"].as_array().unwrap().len(), 1); assert_eq!(locked[0]["options"][0]["value"], "model-b"); - assert_eq!(locked[1]["currentValue"], "approve_all"); - assert_eq!(locked[1]["options"].as_array().unwrap().len(), 2); } #[test] @@ -2889,136 +2702,6 @@ mod tests { assert!(timeline_update(&system_item("Thinking hard"), &mut projection, false).is_none()); } - #[test] - fn legacy_allow_all_cannot_bypass_the_acp_caller() { - // The stale Desktop-owned bypass stays readable only so old files - // keep loading; it can no longer influence a session's mode. - let migrated = normalize_config(AgentAcpConfig { - permission_mode: AgentAcpPermissionMode::AllowAll, - ..AgentAcpConfig::default() - }) - .unwrap(); - assert_eq!(migrated.permission_mode, AgentAcpPermissionMode::ReadOnly); - } - - #[test] - fn permission_request_exposes_only_one_shot_caller_choices() { - let request = AgentPermissionRequest { - request_id: "request-1".to_string(), - tool_name: "developer__shell".to_string(), - arguments: serde_json::Map::from_iter([( - "command".to_string(), - serde_json::json!("git push"), - )]), - prompt: Some("Push this branch?".to_string()), - }; - let item = AgentTimelineItem { - id: "permission-request-1".to_string(), - item_type: "permission".to_string(), - role: Some("system".to_string()), - title: Some("Push branch".to_string()), - text: request.prompt.clone(), - status: Some("pending".to_string()), - input: Some(serde_json::Value::Object(request.arguments.clone())), - output: None, - created_ms: 1, - merge: "replace".to_string(), - }; - let permission = RequestPermissionRequest::new( - "session-1", - acp_permission_tool_call(&request, &item).into(), - acp_permission_options(), - ); - let encoded = serde_json::to_value(permission).unwrap(); - - assert_eq!(encoded["toolCall"]["toolCallId"], "request-1"); - assert_eq!(encoded["toolCall"]["title"], "Push branch"); - assert_eq!(encoded["toolCall"]["kind"], "execute"); - assert_eq!( - encoded["toolCall"]["content"][0]["content"]["text"], - "Push this branch?" - ); - assert_eq!(encoded["toolCall"]["status"], "pending"); - assert_eq!(encoded["toolCall"]["rawInput"]["command"], "git push"); - assert_eq!( - encoded["options"], - serde_json::json!([ - { "optionId": "allow_once", "name": "Allow once", "kind": "allow_once" }, - { "optionId": "reject_once", "name": "Reject once", "kind": "reject_once" } - ]) - ); - } - - #[test] - fn permission_request_without_prompt_previews_the_arguments() { - let path = std::env::temp_dir().join("notes.md"); - assert!(path.is_absolute(), "the approval fixture must be absolute"); - let path = path.to_string_lossy().into_owned(); - let title = format!("edit: {path}"); - let request = AgentPermissionRequest { - request_id: "request-2".to_string(), - tool_name: "edit".to_string(), - arguments: serde_json::Map::from_iter([ - ("path".to_string(), serde_json::json!(path)), - ( - "edits".to_string(), - serde_json::json!([{ "oldText": "foo", "newText": "bar" }]), - ), - ]), - prompt: None, - }; - let item = AgentTimelineItem { - id: "permission-request-2".to_string(), - item_type: "permission".to_string(), - role: Some("system".to_string()), - title: Some(title.clone()), - text: None, - status: Some("pending".to_string()), - input: Some(serde_json::Value::Object(request.arguments.clone())), - output: None, - created_ms: 1, - merge: "replace".to_string(), - }; - let permission = RequestPermissionRequest::new( - "session-1", - acp_permission_tool_call(&request, &item).into(), - acp_permission_options(), - ); - let encoded = serde_json::to_value(permission).unwrap(); - assert_eq!(encoded["toolCall"]["title"], title); - // Edit approvals render as diffs, the shape ACP clients show inline. - assert_eq!(encoded["toolCall"]["content"][0]["type"], "diff"); - assert_eq!(encoded["toolCall"]["content"][0]["path"], path); - assert_eq!(encoded["toolCall"]["content"][0]["oldText"], "foo"); - assert_eq!(encoded["toolCall"]["content"][0]["newText"], "bar"); - // The card links to the file it approves. - assert_eq!(encoded["toolCall"]["locations"][0]["path"], path); - - // One approval card must not flood the caller with a huge edit. - let long = "x".repeat(20_000); - let long_request = AgentPermissionRequest { - request_id: "request-3".to_string(), - tool_name: "edit".to_string(), - arguments: serde_json::Map::from_iter([ - ("path".to_string(), serde_json::json!(path)), - ( - "edits".to_string(), - serde_json::json!([{ "newText": long }]), - ), - ]), - prompt: None, - }; - let bounded = serde_json::to_value(acp_permission_tool_call(&long_request, &item)).unwrap(); - let bounded_text = bounded["content"][0]["newText"] - .as_str() - .expect("the bounded diff must stay attached"); - assert!( - bounded_text.chars().count() <= 501, - "diff text must be bounded, got {}", - bounded_text.chars().count() - ); - } - #[test] fn project_trust_chooser_is_fail_closed_and_cannot_be_auto_accepted() { let encoded = serde_json::to_value(project_trust_permission_options()).unwrap(); @@ -3095,7 +2778,7 @@ mod tests { encoded["message"] .as_str() .unwrap() - .contains("Normal tool permissions still apply") + .contains("Maple runs every tool call without asking") ); } @@ -3110,32 +2793,6 @@ mod tests { assert!(client_supports_form_elicitation(&form)); } - #[test] - fn permission_outcomes_map_fail_closed() { - assert_eq!( - acp_permission_decision(&RequestPermissionOutcome::Selected( - SelectedPermissionOutcome::new("allow_once") - )), - Ok(AgentPermissionDecision::AllowOnce) - ); - assert_eq!( - acp_permission_decision(&RequestPermissionOutcome::Selected( - SelectedPermissionOutcome::new("reject_once") - )), - Ok(AgentPermissionDecision::DenyOnce) - ); - assert_eq!( - acp_permission_decision(&RequestPermissionOutcome::Cancelled), - Ok(AgentPermissionDecision::Cancel) - ); - assert!( - acp_permission_decision(&RequestPermissionOutcome::Selected( - SelectedPermissionOutcome::new("allow_always") - )) - .is_err() - ); - } - #[cfg(unix)] #[tokio::test] async fn outbound_tracker_releases_credit_only_after_a_socket_write_acknowledgement() { @@ -3171,12 +2828,12 @@ mod tests { } #[tokio::test] - async fn cancelled_permission_retains_credit_until_the_orphan_request_settles() { + async fn cancelled_trust_chooser_retains_credit_until_the_orphan_request_settles() { let tracker = AcpOutboundTracker::with_limits(1, 1024); let cancellation = CancellationToken::new(); let first = tracker.reserve(1, &cancellation).await.unwrap(); let (settled_tx, settled_rx) = tokio::sync::oneshot::channel::<()>(); - retain_cancelled_permission_request( + retain_cancelled_caller_request( async move { let _ = settled_rx.await; }, @@ -3231,8 +2888,10 @@ mod tests { assert!(ensure_allowed_project_root(Path::new("relative/project"), &[]).is_err()); } + /// Files from builds that still saved `enabled` and `permissionMode` + /// load; the retired fields are ignored. #[test] - fn a_config_written_before_the_enabled_flag_was_dropped_still_loads() { + fn a_config_written_with_retired_fields_still_loads() { let root = tempfile::tempdir().unwrap(); let user_id = "acp-legacy-user"; let path = config_path(root.path(), user_id).unwrap(); @@ -3245,7 +2904,7 @@ mod tests { let config = load_config(root.path(), user_id).unwrap(); assert_eq!(config.max_connections, 4); - assert_eq!(config.permission_mode, AgentAcpPermissionMode::ReadOnly); + assert!(config.allowed_project_roots.is_empty()); } #[test] diff --git a/apps/maple-agent/crates/maple-agent/src/acp/session.rs b/apps/maple-agent/crates/maple-agent/src/acp/session.rs index adb964d5b..474a9457b 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/session.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/session.rs @@ -24,11 +24,6 @@ use tokio_util::sync::CancellationToken; use std::os::unix::fs::PermissionsExt as _; pub(super) const ACP_TRANSIENT_MCP_TIMEOUT_SECONDS: u64 = 30; -/// Persisted Goose mode for caller-mediated interactive ACP tasks: Maple's -/// smart approvals, where only sensitive tool calls ask the caller. -pub(super) const ACP_LOADABLE_GOOSE_MODE: &str = "smart_approve"; -/// Persisted Goose mode for a task whose every tool call is auto-approved. -pub(super) const ACP_LOADABLE_GOOSE_AUTO_MODE: &str = "auto"; pub(super) const ALLOWED_BRIDGE_ENV: [&str; 6] = [ "BUZZ_RELAY_URL", "BUZZ_PRIVATE_KEY", @@ -77,39 +72,6 @@ pub(super) struct AcpConnectionContext { pub(super) outbound: Arc, } -/// The caller-selected approval policy of one ACP session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(super) enum AcpSessionMode { - #[default] - Interactive, - ApproveAll, -} - -impl AcpSessionMode { - pub(super) fn parse(id: &str) -> Option { - match id { - "interactive" => Some(Self::Interactive), - "approve_all" => Some(Self::ApproveAll), - _ => None, - } - } - - pub(super) fn id(self) -> &'static str { - match self { - Self::Interactive => "interactive", - Self::ApproveAll => "approve_all", - } - } - - /// The Maple runtime permission mode this ACP mode maps onto. - pub(super) fn maple_mode(self) -> &'static str { - match self { - Self::Interactive => ACP_LOADABLE_GOOSE_MODE, - Self::ApproveAll => ACP_LOADABLE_GOOSE_AUTO_MODE, - } - } -} - pub(super) struct AcpSession { pub(super) lease: Option, pub(super) model: String, @@ -125,7 +87,6 @@ pub(super) struct AcpSession { pub(super) prompted: bool, pub(super) project_root: PathBuf, pub(super) project_trust_decision: Option, - pub(super) mode: AcpSessionMode, } pub(super) struct UnpublishedAcpSession { @@ -195,12 +156,7 @@ impl AcpSessionOperation { impl AcpSession { pub(super) fn config_options(&self) -> Vec { - acp_session_config_options( - &self.model, - &self.available_models, - self.message_count, - self.mode, - ) + acp_session_config_options(&self.model, &self.available_models, self.message_count) } } @@ -214,12 +170,6 @@ pub(super) enum AcpPromptState { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum AcpPermissionResolution { - Continue, - Cancelled, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum AcpProjectTrustResolution { Continue, @@ -304,7 +254,7 @@ pub(super) fn prepare_session_mcp( continue; } return Err(agent_client_protocol::Error::invalid_params().data(format!( - "Transient stdio MCP server '{}' is disabled because it would execute caller-supplied native code without a Maple approval boundary", + "Transient stdio MCP server '{}' is disabled because it would execute caller-supplied native code on this machine", server.name ))); } @@ -402,30 +352,17 @@ pub(super) fn canonical_session_id_text( Ok(session_id.to_string()) } -pub(super) fn is_acp_loadable_session_mode(mode: &str) -> bool { - mode == ACP_LOADABLE_GOOSE_MODE - // Tasks persisted while ACP briefly mapped Interactive onto Goose's - // ask-every-time Approve mode stay loadable and re-map to Interactive. - || mode == "approve" - || mode == ACP_LOADABLE_GOOSE_AUTO_MODE -} - -pub(super) fn ensure_acp_session_is_loadable<'a>( +/// The persisted task a caller asked to load. Every task loads, whatever +/// mode it was saved in: all of them run with every tool call allowed. +pub(super) fn find_acp_session<'a>( sessions: &'a [AgentSessionSummary], session_id: &str, ) -> Result<&'a AgentSessionSummary, String> { - let session = sessions + sessions .iter() .find(|session| session.id == session_id) .ok_or_else(|| { "The requested Maple Agent task does not exist in the supplied project directory" .to_string() - })?; - if !is_acp_loadable_session_mode(&session.mode) { - return Err( - "Maple ACP can load only Read only and Approve all Agent tasks; this task remains available in Maple Desktop" - .to_string(), - ); - } - Ok(session) + }) } diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index 97dcf8ea0..ac30e275a 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -5617,9 +5617,6 @@ impl AgentRuntimeHandle { // followed by this send path re-appending the cancelled prompt. drop(session_lifecycle_guard.take()); - let permission_responder = - matches!(permission_routing, AgentPermissionRouting::CallingSurface) - .then_some(AgentRunPermissionResponder); let cancellation = matches!(permission_routing, AgentPermissionRouting::CallingSurface) .then(|| AgentRunCancellation { agent: self.clone(), @@ -5633,7 +5630,6 @@ impl AgentRuntimeHandle { terminal: terminal_rx, usage: usage_rx, event_overflowed: run_events.overflow_flag(), - permission_responder, cancellation, queued: None, queue: started_queue, @@ -8992,7 +8988,6 @@ fn steered_run_handle(run_id: String, queue: AgentDesktopQueueSnapshot) -> Agent terminal, usage, event_overflowed: Arc::new(AtomicBool::new(false)), - permission_responder: None, cancellation: None, queued: None, queue, @@ -9013,7 +9008,6 @@ fn staged_run_handle( terminal, usage, event_overflowed: Arc::new(AtomicBool::new(false)), - permission_responder: None, cancellation: None, queued: Some(queued), queue, diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 0793d4f87..daec6f39a 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -638,7 +638,6 @@ pub struct AgentRunHandle { pub terminal: watch::Receiver>, pub usage: watch::Receiver>, pub event_overflowed: Arc, - pub(crate) permission_responder: Option, pub(crate) cancellation: Option, pub queued: Option, pub queue: AgentDesktopQueueSnapshot, @@ -697,21 +696,6 @@ pub(super) fn nonnegative_tokens(tokens: Option) -> u64 { .unwrap_or(0) } -/// Kept for surfaces that still hold one. Maple no longer asks for tool -/// permissions, so there is never a request to answer. -#[derive(Clone)] -pub(crate) struct AgentRunPermissionResponder; - -impl AgentRunPermissionResponder { - pub async fn respond( - &self, - _request_id: String, - _decision: AgentPermissionDecision, - ) -> Result<(), String> { - Err("Maple no longer asks for tool permissions".to_string()) - } -} - /// Opaque cancellation capability for one run owned by a calling surface. /// /// Unlike the Desktop command boundary, an adapter already has the exact run