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