From 2673f240139a5b452f606827cd96a508d801b25f Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:20:51 +0000 Subject: [PATCH 1/2] maple-agent: cut the standalone CuaDriver backend Built-in CUA is the only computer-use backend. Gone: the macOS discovery of /Applications/CuaDriver.app, the manifest probe and its parser, the `external` integration backend and the stdio server definition the device-local registry stored for it, the version-1 registry migration, the legacy-task detection by persisted stdio entry, the external arms in default selection, backend selection, task row projection and the per-task MCP toggle, `standalone_version` on integration cards, and the app's "Standalone driver"/"Standalone detected" labels and "Set up built-in" action. Old state is tolerated, not migrated. A device-local file whose CUA entry selected the retired backend loads with that entry dropped (version-1 files are unsupported and read as empty), so the card reads "not set up" until built-in CUA is enabled. A task saved with the driver's stdio entry has that entry stripped from its extension list, under every spelling the integration reserves, before Goose builds or reuses the task's agent; Goose would otherwise start the executable during the cold build, ahead of Maple's run setup. The per-task CUA state those tasks saved as `external` no longer deserializes and falls back to the device default. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 11 +- apps/maple-agent/app/src/ui/chat/mod.rs | 1 - apps/maple-agent/app/src/ui/chat/tests.rs | 7 +- apps/maple-agent/app/src/ui/settings.rs | 61 +- .../app/src/ui/settings/navigation.rs | 4 +- .../crates/maple-agent/src/agent.rs | 219 +++++- .../maple-agent/src/agent/integrations.rs | 684 +++--------------- .../crates/maple-agent/src/agent/types.rs | 7 +- apps/maple-agent/docs/embedded-cua.md | 18 +- 9 files changed, 347 insertions(+), 665 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index b0df630e8..45b88888a 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -145,12 +145,11 @@ the embedded transport. Enabling an integration sets a device-local default for new tasks. Existing tasks keep their frozen integration choice and expose CUA as an independent per-task switch in the composer. A task that never chose a backend adopts the -device default only when it can actually run it. A detected standalone -CuaDriver, which Maple looks for on macOS only, remains a legacy-compatible -backend until the user explicitly sets up the built-in one; -Maple does not install or update it, start or stop its daemon, or alter another -client's configuration. Custom STDIO and Streamable HTTP MCP servers remain -account configuration that may roam between devices. +device default only when it can actually run it. Maple no longer looks for a +separately installed CuaDriver application: a task saved with its stdio entry +loses that entry on its next run, and a device setting that selected it reads +as not set up. Custom STDIO and Streamable HTTP MCP servers remain account +configuration that may roam between devices. The embedded design, migration rules, privacy boundary, and preview limits are documented in [`docs/embedded-cua.md`](docs/embedded-cua.md). diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 3bebb72f8..582160e81 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -303,7 +303,6 @@ fn draft_mcp_rows( description: card.description.clone(), transport: match card.backend { Some(AgentIntegrationBackend::Embedded) => "embedded", - Some(AgentIntegrationBackend::External) => "stdio", None => "unconfigured", } .to_string(), diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index ce882d516..404f4cbb3 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -2525,7 +2525,6 @@ mod state_tests { availability, backend, version: None, - standalone_version: None, permissions: None, setup_available: false, enabled_for_new_tasks, @@ -2610,7 +2609,7 @@ mod state_tests { assert_eq!(rows[1].display_name, "CODEX"); // Without a stored CUA choice the row is unconfigured and off; an - // external backend that is not installed is on but unavailable. + // embedded choice whose grants are missing is on but unavailable. let rows = draft_mcp_rows( Vec::new(), &[integration_card( @@ -2628,12 +2627,12 @@ mod state_tests { &[integration_card( "cua-driver", AgentIntegrationAvailability::SetupRequired, - Some(AgentIntegrationBackend::External), + Some(AgentIntegrationBackend::Embedded), true, )], ); assert!(rows[0].enabled && !rows[0].available); - assert_eq!(rows[0].transport, "stdio"); + assert_eq!(rows[0].transport, "embedded"); // No catalog at all: the servers alone, as before. let rows = draft_mcp_rows(vec![mcp_server("docs", false)], &[]); assert_eq!(rows.len(), 1); diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index ee08ebc68..386ba5a2d 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -2402,15 +2402,7 @@ impl SettingsScreen { || SettingsTarget::IntegrationSetup(integration.id.clone()), pill_button( format!("integration-setup-{}", integration.id), - if matches!( - &integration.backend, - Some(AgentIntegrationBackend::External) - ) || integration.standalone_version.is_some() - { - "Set up built-in" - } else { - "Set up" - }, + "Set up", false, cx.listener(move |this, _event, _window, cx| { this.setup_integration(&id, cx); @@ -2723,16 +2715,7 @@ fn integration_can_enable(integration: &AgentIntegration) -> bool { ); } - match integration.backend { - Some(AgentIntegrationBackend::Embedded) => { - cua_permissions_ready(integration.permissions.as_ref()) - } - Some(AgentIntegrationBackend::External) => integration.standalone_version.is_some(), - None => { - cua_permissions_ready(integration.permissions.as_ref()) - || integration.standalone_version.is_some() - } - } + cua_permissions_ready(integration.permissions.as_ref()) } fn integration_can_toggle(integration: &AgentIntegration) -> bool { @@ -2746,11 +2729,7 @@ fn integration_can_setup(integration: &AgentIntegration) -> bool { integration.availability, AgentIntegrationAvailability::NotDetected ) - && (!cua_permissions_ready(integration.permissions.as_ref()) - || matches!( - &integration.backend, - Some(AgentIntegrationBackend::External) - )) + && !cua_permissions_ready(integration.permissions.as_ref()) } fn integration_availability(integration: &AgentIntegration) -> (&'static str, u32) { @@ -2768,9 +2747,7 @@ fn integration_availability(integration: &AgentIntegration) -> (&'static str, u3 match &integration.availability { AgentIntegrationAvailability::NotDetected => ("Not detected", theme::text_muted()), AgentIntegrationAvailability::Available if is_cua_driver(&integration.id) => { - if cua_permissions_ready(integration.permissions.as_ref()) - || integration.standalone_version.is_some() - { + if cua_permissions_ready(integration.permissions.as_ref()) { ("Ready", theme::status_success()) } else { ("Available", theme::status_success()) @@ -2791,16 +2768,8 @@ fn integration_setup_notice(permissions: &AgentIntegrationPermissions) -> Option Some(format!("{}: {guidance}", missing.label())) } -fn cua_backend_label(integration: &AgentIntegration) -> &'static str { - match integration.backend { - Some(AgentIntegrationBackend::External) => "Standalone driver", - None if integration.standalone_version.is_some() - && !cua_permissions_ready(integration.permissions.as_ref()) => - { - "Standalone detected" - } - Some(AgentIntegrationBackend::Embedded) | None => "Built into Maple", - } +fn cua_backend_label(_integration: &AgentIntegration) -> &'static str { + "Built into Maple" } fn integration_status_badge(label: &'static str, color: u32) -> Div { @@ -3161,7 +3130,6 @@ mod tests { detail: None, permissions: Some(macos_permissions(ready, ready)), setup_available: true, - standalone_version: None, backend: Some(AgentIntegrationBackend::Embedded), } } @@ -3263,18 +3231,11 @@ mod tests { assert!(integration_is_visible(&claude_needs_setup)); assert!(!integration_can_enable(&claude_needs_setup)); - let mut external = integration(AgentIntegrationAvailability::Available, true); - external.backend = Some(AgentIntegrationBackend::External); - external.permissions = Some(macos_permissions(false, false)); - external.standalone_version = Some("0.23.2".to_string()); - assert!(integration_can_toggle(&external)); - assert!(integration_can_setup(&external)); - - // A separately installed driver must not make an embedded selection - // enableable after Maple's own grants have been revoked. The setup - // action is the only route back to a ready embedded backend. - let mut revoked_embedded = external.clone(); - revoked_embedded.backend = Some(AgentIntegrationBackend::Embedded); + // An embedded selection is not enableable after Maple's own grants + // have been revoked. The setup action is the only route back to a + // ready embedded backend. + let mut revoked_embedded = integration(AgentIntegrationAvailability::Available, true); + revoked_embedded.permissions = Some(macos_permissions(false, false)); revoked_embedded.enabled_for_new_tasks = false; assert!(!integration_can_enable(&revoked_embedded)); assert!(!integration_can_toggle(&revoked_embedded)); diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index b8252d4dc..2336765e9 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -700,8 +700,7 @@ mod tests { ), ), setup_available: true, - standalone_version: Some("0.23.2".to_string()), - backend: Some(maple_agent::agent::AgentIntegrationBackend::External), + backend: Some(maple_agent::agent::AgentIntegrationBackend::Embedded), }, // An undetected integration contributes no focus target. maple_agent::agent::AgentIntegration { @@ -714,7 +713,6 @@ mod tests { detail: None, permissions: None, setup_available: false, - standalone_version: None, backend: None, }, ]); diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index 17c297288..006cc9068 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -4053,18 +4053,6 @@ impl AgentRuntimeHandle { attach_prepared_skills_client(&agent, skills_client).await; mutation_result?; - if requested_key == CUA_DRIVER_MCP_NAME { - persist_session_cua_state( - session_manager.as_ref(), - &session_id, - CuaSessionState { - backend: AgentIntegrationBackend::External, - enabled: request.enabled, - }, - ) - .await?; - } - let refreshed = session_manager .get_session(&session_id, false) .await @@ -6563,10 +6551,25 @@ async fn get_or_create_session_agent( where T: provider::MapleInferenceTransport + 'static, { + // Tasks saved by builds that offered the standalone CuaDriver carry its + // stdio entry in their extension list, and Goose starts every persisted + // stdio extension while it builds a cold agent. Strip the entry first, + // under every spelling the integration reserves, so that executable is + // never run; a cached agent that still holds it is detached too. + let retired_extensions = strip_retired_cua_driver_extension(agent_manager, session).await?; let manager_result = agent_manager .get_or_create_agent_with_runtime_context(session.id.clone(), runtime_context) .await .map_err(|e| format!("Failed to load Agent for task {}: {e}", session.id))?; + if !manager_result.agent_created { + for name in &retired_extensions { + let _ = manager_result + .agent + .extension_manager + .remove_extension(name) + .await; + } + } // Freshly created agents inherit goose's stock identity prompt, which // names goose and AAIF even though users only ever meet Maple's Agent @@ -6604,6 +6607,57 @@ where Ok(manager_result) } +/// Remove the retired standalone CuaDriver's stdio entry from a task's saved +/// extension list before Goose builds (or reuses) the task's agent. Returns +/// the names it removed, so a cached agent can be detached from them too. +async fn strip_retired_cua_driver_extension( + agent_manager: &Arc, + session: &Session, +) -> Result, String> { + let Some(state) = + goose::session::EnabledExtensionsState::from_extension_data(&session.extension_data) + else { + return Ok(Vec::new()); + }; + let retired = state + .extensions + .iter() + .filter(|config| mcp_transport_label(config).is_some() && is_cua_identity(&config.name())) + .map(ExtensionConfig::name) + .collect::>(); + if retired.is_empty() { + return Ok(Vec::new()); + } + let session_manager = agent_manager.session_manager(); + let current = session_manager + .get_session(&session.id, false) + .await + .map_err(|error| format!("Failed to load Agent task: {error}"))?; + let mut extension_data = current.extension_data.clone(); + // Filter the freshly read list, not the caller's snapshot, so a change + // made since the snapshot is kept. + let kept = goose::session::EnabledExtensionsState::from_extension_data(&extension_data) + .map(|state| state.extensions) + .unwrap_or(state.extensions) + .into_iter() + .filter(|config| !retired.contains(&config.name())) + .collect::>(); + goose::session::EnabledExtensionsState::new(kept) + .to_extension_data(&mut extension_data) + .map_err(|error| format!("Failed to update the task's extension list: {error}"))?; + session_manager + .update(&session.id) + .extension_data(extension_data) + .apply() + .await + .map_err(|error| format!("Failed to save the task's extension list: {error}"))?; + log::info!( + "Removed the retired standalone CuaDriver entry from task {}", + session.id + ); + Ok(retired) +} + /// Await an MCP startup that runs with Maple's lifecycle fences released. /// /// `cancel` is the run's own token and `runtime_lifetime` is the token Stop @@ -9975,6 +10029,147 @@ mod tests { assert!(state.inner.lock().await.is_none()); } + /// A task saved by the first integrations preview carries the standalone + /// CuaDriver's stdio entry, and Goose starts every persisted stdio + /// extension while it builds a cold agent. The entry is stripped before + /// that build, so the executable never runs; a later compaction, which + /// builds an agent with no run setup at all, does not run it either. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn persisted_cua_driver_stdio_entry_is_stripped_before_any_agent_starts() { + let sink = Arc::new(RecordingAgentEventSink::default()); + let (test_root, paths, state) = agent_service_test_context("retired-cua-stdio", sink); + let user_id = "retired-cua-stdio-user"; + let account_scope = account_scope(user_id).unwrap(); + let project_root = test_root.join("project"); + fs::create_dir_all(&project_root).unwrap(); + let marker = test_root.join("cua-driver-ran"); + let sentinel = test_root.join("cua-driver"); + fs::write( + &sentinel, + format!("#!/bin/sh\ntouch '{}'\nsleep 30\n", marker.display()), + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&sentinel, fs::Permissions::from_mode(0o755)).unwrap(); + } + let session_manager = Arc::new(account_session_manager(&paths, user_id).unwrap()); + let permission_manager = Arc::new(PermissionManager::new(test_root.join("permissions"))); + let session = session_manager + .create_session( + project_root.clone(), + "Old CuaDriver task".to_string(), + SessionType::User, + GooseMode::Auto, + ) + .await + .unwrap(); + let mut extension_data = session.extension_data.clone(); + goose::session::EnabledExtensionsState::new(vec![ExtensionConfig::Stdio { + name: CUA_DRIVER_MCP_NAME.to_string(), + description: "Control desktop applications through the locally installed Cua Driver." + .to_string(), + cmd: sentinel.to_string_lossy().into_owned(), + args: vec!["mcp".to_string()], + envs: Default::default(), + env_keys: Vec::new(), + timeout: Some(DEFAULT_MCP_TIMEOUT_SECONDS), + cwd: None, + bundled: None, + available_tools: Vec::new(), + }]) + .to_extension_data(&mut extension_data) + .unwrap(); + session_manager + .update(&session.id) + .extension_data(extension_data) + .apply() + .await + .unwrap(); + let agent_manager = Arc::new( + AgentManager::new( + GooseAgentConfig::new( + Arc::clone(&session_manager), + permission_manager, + None, + GooseMode::Auto, + true, + GoosePlatform::GooseDesktop, + ), + Some(2), + ) + .await + .unwrap(), + ); + *state.inner.lock().await = Some(AgentRuntime { + agent_manager: Arc::clone(&agent_manager), + session_manager: Arc::clone(&session_manager), + maple_api_session: crate::maple_api::test_maple_api_session(user_id), + active_runs: HashMap::new(), + session_title_tasks: HashMap::new(), + session_tool_contexts: HashMap::new(), + external_agents: None, + project_root: project_root.clone(), + model: DEFAULT_AGENT_MODEL.to_string(), + account_scope: account_scope.clone(), + lifetime: CancellationToken::new(), + }); + let handle = state.handle_for_user(user_id).await.unwrap(); + let saved_list_is_clean = || async { + let saved = session_manager + .get_session(&session.id, false) + .await + .unwrap(); + !session_mcp_extension_keys(&saved) + .iter() + .any(|key| is_cua_key(key)) + }; + + // Compaction builds the cold agent with no run setup at all. The + // model is unreachable in this runtime, so the compaction itself + // fails; what matters is what Goose started while building the agent. + let _ = handle.compact_session(session.id.clone()).await; + assert!( + !marker.exists(), + "the retired driver must not have been started by the cold build" + ); + assert!( + saved_list_is_clean().await, + "the saved extension list no longer names the driver" + ); + + // A send reuses the cached agent. Wait only for the run to start: + // the provider retries against the unreachable endpoint, so the run + // would not end on its own in test time. + let mut run = handle + .send_message(AgentSendMessageRequest { + session_id: session.id.clone(), + text: "hello".to_string(), + model: None, + context_limit: None, + vision_capable: false, + steer: false, + queue_id: None, + attachments: Vec::new(), + }) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(60), async { + while let Some(event) = run.events.recv().await { + if matches!(event, AgentRunEvent::Started) { + break; + } + } + }) + .await + .expect("the run should start"); + assert!(!marker.exists()); + assert!(saved_list_is_clean().await); + handle.stop().await.unwrap(); + let _ = fs::remove_dir_all(test_root); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn desktop_send_during_active_run_stages_a_native_queue() { let sink = Arc::new(RecordingAgentEventSink::default()); diff --git a/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs b/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs index bbd805b65..9449b9106 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs @@ -9,10 +9,6 @@ use super::external_agents::claude::{self, ClaudeDetection}; use super::external_agents::codex::{self, CodexDetection}; use super::*; use std::collections::HashSet; -#[cfg(target_os = "macos")] -use std::process::Stdio; -#[cfg(target_os = "macos")] -use std::time::Duration; pub(super) const CUA_DRIVER_INTEGRATION_ID: &str = "cua-driver"; /// The name Goose shows for the extension and that Maple uses when it has to @@ -26,10 +22,6 @@ pub(super) const CUA_DRIVER_DESCRIPTION: &str = /// renders what it is given instead of keeping a second set of strings. const CUA_DRIVER_CARD_NAME: &str = "Cua"; const CUA_DRIVER_CARD_DESCRIPTION: &str = "Let Maple see and control apps on this computer."; -const CUA_EXTERNAL_MCP_DESCRIPTION: &str = - "Control desktop applications through the locally installed Cua Driver."; -#[cfg(target_os = "macos")] -const CUA_DRIVER_MACOS_BINARY: &str = "/Applications/CuaDriver.app/Contents/MacOS/cua-driver"; /// The Codex CLI as an external agent a task can hand work to. pub(super) const CODEX_INTEGRATION_ID: &str = "codex"; const CODEX_CARD_NAME: &str = "Codex"; @@ -169,13 +161,6 @@ const EXTERNAL_AGENT_SKILLS: [(&str, &str); 3] = [ ]; const INTEGRATIONS_FILE_NAME: &str = "integrations.json"; const INTEGRATIONS_FILE_VERSION: u32 = 2; -const LEGACY_INTEGRATIONS_FILE_VERSION: u32 = 1; -#[cfg(any(target_os = "macos", test))] -const CUA_MANIFEST_SCHEMA_VERSION: &str = "1"; -#[cfg(target_os = "macos")] -const CUA_PROBE_TIMEOUT: Duration = Duration::from_secs(3); -#[cfg(target_os = "macos")] -const MAX_CUA_MANIFEST_BYTES: usize = 64 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)] pub(super) struct StoredIntegrationRegistry { @@ -246,9 +231,8 @@ fn codex_public( name: CODEX_CARD_NAME.to_string(), description: CODEX_CARD_DESCRIPTION.to_string(), availability, - backend: stored.map(|entry| entry.backend), + backend: stored.map(|entry| entry.backend.public()), version: detection.version.clone(), - standalone_version: None, permissions: None, setup_available: false, enabled_for_new_tasks: stored.is_some_and(|entry| entry.enabled), @@ -270,9 +254,8 @@ fn claude_public( (_, Some(_)) => AgentIntegrationAvailability::SetupRequired, _ => AgentIntegrationAvailability::Available, }, - backend: stored.map(|entry| entry.backend), + backend: stored.map(|entry| entry.backend.public()), version: detection.version.clone(), - standalone_version: None, permissions: None, setup_available: false, enabled_for_new_tasks: stored.is_some_and(|entry| entry.enabled), @@ -400,39 +383,26 @@ pub(super) fn sync_external_agent_skills( struct StoredIntegration { id: String, enabled: bool, - backend: AgentIntegrationBackend, - #[serde(default, skip_serializing_if = "Option::is_none")] - external_server: Option, + backend: StoredBackend, } -#[derive(Debug, Clone, Deserialize)] -struct LegacyStoredIntegrationRegistry { - version: u32, - #[serde(default)] - integrations: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct LegacyStoredIntegration { - id: String, - server: AgentMcpServer, +/// The backend column of the device-local file. Files written by builds that +/// offered the standalone CuaDriver say `external`; those entries are dropped +/// on load, not migrated, so the file stays readable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum StoredBackend { + Embedded, + #[serde(other)] + Retired, } -#[cfg(any(target_os = "macos", test))] -#[derive(Debug, Deserialize)] -struct CuaManifest { - schema_version: String, - binary_path: String, - binary_version: String, - mcp_invocation: CuaMcpInvocation, -} - -#[cfg(any(target_os = "macos", test))] -#[derive(Debug, Deserialize)] -struct CuaMcpInvocation { - command: String, - args: Vec, +impl StoredBackend { + fn public(self) -> AgentIntegrationBackend { + match self { + Self::Embedded | Self::Retired => AgentIntegrationBackend::Embedded, + } + } } #[derive(Debug)] @@ -440,9 +410,7 @@ pub(super) struct CuaDetection { availability: AgentIntegrationAvailability, permissions: Option, setup_available: bool, - standalone_version: Option, detail: Option, - external_server: Option, } impl CuaDetection { @@ -452,9 +420,7 @@ impl CuaDetection { availability: AgentIntegrationAvailability::NotDetected, permissions: None, setup_available: false, - standalone_version: None, detail: Some("Built-in CUA is not available on this operating system yet.".to_string()), - external_server: None, } } @@ -470,9 +436,8 @@ impl CuaDetection { name: CUA_DRIVER_CARD_NAME.to_string(), description: CUA_DRIVER_CARD_DESCRIPTION.to_string(), availability: self.availability, - backend: stored.map(|entry| entry.backend), + backend: stored.map(|entry| entry.backend.public()), version: embedded_cua_version(), - standalone_version: self.standalone_version.clone(), permissions: self.permissions.clone(), setup_available: self.setup_available, enabled_for_new_tasks: stored.is_some_and(|entry| entry.enabled), @@ -579,8 +544,7 @@ fn set_external_agent_default( None if enabled => stored.integrations.push(StoredIntegration { id: provider.id.to_string(), enabled, - backend: AgentIntegrationBackend::Embedded, - external_server: None, + backend: StoredBackend::Embedded, }), None => {} } @@ -617,44 +581,22 @@ fn set_cua_default( if enabled { ensure_no_custom_integration_collision(&custom)?; + if !detection.embedded_ready() { + return Err( + "Set up Maple's Accessibility and Screen Recording permissions before enabling built-in CUA" + .to_string(), + ); + } match stored.cua_mut() { Some(entry) => { - match entry.backend { - AgentIntegrationBackend::Embedded if !detection.embedded_ready() => { - return Err( - "Set up Maple's Accessibility and Screen Recording permissions before enabling built-in CUA" - .to_string(), - ); - } - AgentIntegrationBackend::External => { - let server = detection.external_server.clone().ok_or_else(|| { - "The standalone CuaDriver selected by this setting is no longer available. Set up built-in CUA instead." - .to_string() - })?; - entry.external_server = Some(server); - } - AgentIntegrationBackend::Embedded => {} - } + entry.backend = StoredBackend::Embedded; entry.enabled = true; } - None if detection.embedded_ready() => { - stored.integrations.push(StoredIntegration { - id: CUA_DRIVER_INTEGRATION_ID.to_string(), - enabled: true, - backend: AgentIntegrationBackend::Embedded, - external_server: detection.external_server.clone(), - }); - } None => { - let server = detection.external_server.clone().ok_or_else(|| { - "Set up Maple's Accessibility and Screen Recording permissions before enabling CUA" - .to_string() - })?; stored.integrations.push(StoredIntegration { id: CUA_DRIVER_INTEGRATION_ID.to_string(), enabled: true, - backend: AgentIntegrationBackend::External, - external_server: Some(server), + backend: StoredBackend::Embedded, }); } } @@ -665,9 +607,8 @@ fn set_cua_default( save_stored_integrations(paths, user_id, &stored) } -/// Switch the new-task default to Maple's embedded backend only after the OS -/// reports both grants. An incomplete setup never silently takes a working -/// external backend away from the user. +/// Record Maple's embedded backend as the new-task default once the OS +/// reports both grants. An incomplete setup leaves the stored choice alone. pub(super) fn select_embedded_integration_backend( paths: &AgentPathLayout, user_id: &str, @@ -685,17 +626,11 @@ pub(super) fn select_embedded_integration_backend( ensure_no_custom_integration_collision(&custom)?; let mut stored = load_stored_integrations(paths, user_id)?; match stored.cua_mut() { - Some(entry) => { - entry.backend = AgentIntegrationBackend::Embedded; - if detection.external_server.is_some() { - entry.external_server = detection.external_server.clone(); - } - } + Some(entry) => entry.backend = StoredBackend::Embedded, None => stored.integrations.push(StoredIntegration { id: CUA_DRIVER_INTEGRATION_ID.to_string(), enabled: false, - backend: AgentIntegrationBackend::Embedded, - external_server: detection.external_server.clone(), + backend: StoredBackend::Embedded, }), } save_stored_integrations(paths, user_id, &stored)?; @@ -707,24 +642,14 @@ pub(super) fn effective_mcp_servers( custom: Vec, ) -> Result, String> { let mut servers = custom; - if let Some(entry) = stored.cua() { - // The integration owns this product identity even when the embedded - // backend has no external server definition. A custom server that a + if stored.cua().is_some() { + // The integration owns this product identity. A custom server that a // previous release accepted under any historical spelling is shadowed // before Goose can select or start it. Merging would make // normalization fail and lock the account out of every task, while // starting it briefly before embedded CUA replaces it would cross the // explicit backend boundary. servers.retain(|candidate| !is_cua_identity(&candidate.name)); - let Some(server) = entry.external_server.as_ref() else { - return normalize_mcp_servers(servers); - }; - let mut server = server.clone(); - // Keep the concrete external definition available to old tasks, - // but only select it by default while External owns the global - // default. Embedded sessions are represented in Maple metadata. - server.enabled = entry.enabled && entry.backend == AgentIntegrationBackend::External; - servers.push(server); } normalize_mcp_servers(servers) } @@ -735,7 +660,7 @@ pub(super) fn is_cua_key(name: &str) -> bool { } /// Whether a configured server uses any spelling reserved for curated CUA. -fn is_cua_identity(name: &str) -> bool { +pub(super) fn is_cua_identity(name: &str) -> bool { let key = goose::config::extensions::name_to_key(name.trim()); [ CUA_DRIVER_MCP_NAME, @@ -782,7 +707,7 @@ pub(super) fn cua_state_for_new_session( let explicitly_requested = requested_names.map(|names| names.iter().any(|name| is_cua_key(name))); let enabled = explicitly_requested.unwrap_or(entry.enabled); - if entry.backend == AgentIntegrationBackend::Embedded && !allow_embedded { + if !allow_embedded { if explicitly_requested == Some(true) { return Err( "Built-in CUA is available only to tasks running in the Maple desktop app" @@ -792,7 +717,7 @@ pub(super) fn cua_state_for_new_session( return Ok(None); } Ok(Some(CuaSessionState { - backend: entry.backend, + backend: entry.backend.public(), enabled, })) } @@ -828,16 +753,8 @@ pub(super) fn session_cua_backend( if let Some(state) = session_cua_state(session) { return Some(state.backend); } - if session_mcp_extension_keys(session) - .iter() - .any(|name| is_cua_key(name)) - { - // Tasks created by the external-driver PR predate Maple's logical - // metadata. Their concrete persisted stdio extension is unambiguous. - return Some(AgentIntegrationBackend::External); - } - let backend = stored.cua().map(|entry| entry.backend)?; - if backend == AgentIntegrationBackend::Embedded && session.session_type != SessionType::User { + let backend = stored.cua().map(|entry| entry.backend.public())?; + if session.session_type != SessionType::User { return None; } Some(backend) @@ -852,9 +769,6 @@ pub(super) fn project_session_mcp_servers( servers.retain(|server| !is_cua_key(&server.name)); let state = session_cua_state(session); - let active_external = session_mcp_extension_keys(session) - .iter() - .any(|name| is_cua_key(name)); if session.session_type == SessionType::User { servers.extend( EXTERNAL_AGENT_INTEGRATIONS @@ -876,17 +790,12 @@ pub(super) fn project_session_mcp_servers( if backend.is_none() && session.session_type != SessionType::User { return Ok(servers); } - let stored = stored.cua(); let enabled = match backend { Some(AgentIntegrationBackend::Embedded) => state.is_some_and(|state| state.enabled), - Some(AgentIntegrationBackend::External) => active_external, None => false, }; let available = match backend { Some(AgentIntegrationBackend::Embedded) => embedded_cua_ready(), - Some(AgentIntegrationBackend::External) => { - active_external || stored.is_some_and(|entry| entry.external_server.is_some()) - } None => false, }; servers.push(AgentSessionMcpServer { @@ -896,7 +805,6 @@ pub(super) fn project_session_mcp_servers( description: CUA_DRIVER_CARD_DESCRIPTION.to_string(), transport: match backend { Some(AgentIntegrationBackend::Embedded) => "embedded", - Some(AgentIntegrationBackend::External) => "stdio", None => "unconfigured", } .to_string(), @@ -978,52 +886,27 @@ fn load_stored_integrations( .get("version") .and_then(Value::as_u64) .ok_or_else(|| "Device-local integration settings have no version".to_string())?; - let registry = match u32::try_from(version) { + let mut registry: StoredIntegrationRegistry = match u32::try_from(version) { Ok(INTEGRATIONS_FILE_VERSION) => serde_json::from_str(&contents).map_err(|error| { format!("Failed to parse device-local integration settings: {error}") })?, - Ok(LEGACY_INTEGRATIONS_FILE_VERSION) => { - let legacy: LegacyStoredIntegrationRegistry = - serde_json::from_str(&contents).map_err(|error| { - format!("Failed to parse legacy device-local integration settings: {error}") - })?; - migrate_legacy_registry(legacy)? - } _ => { return Err(format!( "Unsupported device-local integration settings version {version}" )); } }; - validate_stored_registry(registry) -} - -fn migrate_legacy_registry( - registry: LegacyStoredIntegrationRegistry, -) -> Result { - if registry.version != LEGACY_INTEGRATIONS_FILE_VERSION { - return Err(format!( - "Unsupported legacy integration settings version {}", - registry.version - )); - } - let integrations = registry + // Entries that chose the retired standalone CuaDriver are dropped, not + // migrated: the file stays readable and the card falls back to "not set + // up" until the user enables built-in CUA. + let before = registry.integrations.len(); + registry .integrations - .into_iter() - .map(|entry| { - let enabled = entry.server.enabled; - StoredIntegration { - id: entry.id, - enabled, - backend: AgentIntegrationBackend::External, - external_server: Some(entry.server), - } - }) - .collect(); - Ok(StoredIntegrationRegistry { - version: INTEGRATIONS_FILE_VERSION, - integrations, - }) + .retain(|entry| entry.backend != StoredBackend::Retired); + if registry.integrations.len() != before { + log::warn!("Ignoring a device-local integration entry that selected a retired backend"); + } + validate_stored_registry(registry) } fn validate_stored_registry( @@ -1045,49 +928,10 @@ fn validate_stored_registry( .to_string(), ); } - if external_agent_selection(&entry.id).is_some() { - if entry.backend != AgentIntegrationBackend::Embedded || entry.external_server.is_some() - { - return Err("Device-local external agent settings are invalid".to_string()); - } - continue; - } - if entry.backend == AgentIntegrationBackend::External && entry.external_server.is_none() { - return Err( - "Device-local external Cua Driver settings have no server definition".to_string(), - ); - } - if let Some(server) = entry.external_server.as_ref() { - validate_stored_cua_server(server)?; - } } Ok(registry) } -fn validate_stored_cua_server(server: &AgentMcpServer) -> Result<(), String> { - if server.name != CUA_DRIVER_MCP_NAME - || server.description != CUA_EXTERNAL_MCP_DESCRIPTION - || server.timeout_seconds != DEFAULT_MCP_TIMEOUT_SECONDS - { - return Err("Device-local Cua Driver settings are invalid".to_string()); - } - let AgentMcpTransport::Stdio { - command, - environment, - } = &server.transport - else { - return Err("Device-local Cua Driver transport is invalid".to_string()); - }; - if !environment.is_empty() { - return Err("Device-local Cua Driver environment must be empty".to_string()); - } - let parts = split_mcp_command(command, CUA_DRIVER_MCP_NAME)?; - if parts.len() != 2 || parts[1] != "mcp" || !Path::new(&parts[0]).is_absolute() { - return Err("Device-local Cua Driver command is invalid".to_string()); - } - Ok(()) -} - fn save_stored_integrations( paths: &AgentPathLayout, user_id: &str, @@ -1107,216 +951,21 @@ async fn detect_cua_driver() -> CuaDetection { #[cfg(embedded_cua)] { let permissions = super::cua::embedded_cua_permission_status(); - // Only macOS ships a standalone CuaDriver application at a path Maple - // knows. Everywhere else the built-in runtime is the only backend, so - // there is nothing to discover and no foreign executable to run. - let (standalone_version, external_server, standalone_error) = - detect_standalone_driver().await; - let embedded_ready = permissions.ready(); - let availability = if embedded_ready || external_server.is_some() { + let availability = if permissions.ready() { AgentIntegrationAvailability::Available } else { AgentIntegrationAvailability::SetupRequired }; // Say what is missing on the card itself, and say it in terms of what // is left to do rather than repeating the requirement's name. - let detail = super::cua::desktop_helper_hint() - .or_else(|| { - standalone_error.map(|error| { - format!( - "Maple could not verify the standalone CuaDriver installation: {error}. Built-in CUA can still be set up." - ) - }) - }); + let detail = super::cua::desktop_helper_hint(); CuaDetection { availability, permissions: Some(permissions), setup_available: super::cua::desktop_setup_available(), - standalone_version, detail, - external_server, - } - } -} - -/// Discover a separately installed CuaDriver application, if this platform has -/// one at a path Maple knows. -#[cfg(target_os = "macos")] -async fn detect_standalone_driver() -> (Option, Option, Option) { - let candidate = PathBuf::from(CUA_DRIVER_MACOS_BINARY); - let exists = match candidate.try_exists() { - Ok(exists) => exists, - Err(error) => { - return ( - None, - None, - Some(format!( - "could not inspect the standalone application: {error}" - )), - ); - } - }; - if !exists { - return (None, None, None); - } - if let Err(error) = ensure_standalone_binary_is_protected(&candidate) { - return (None, None, Some(error)); - } - match probe_cua_manifest(&candidate).await { - Ok((version, server)) => (Some(version), Some(server), None), - Err(error) => (None, None, Some(error)), - } -} - -#[cfg(all(embedded_cua, not(target_os = "macos")))] -async fn detect_standalone_driver() -> (Option, Option, Option) { - (None, None, None) -} - -/// Refuse to execute a driver that any other account can rewrite. -/// -/// Maple runs this binary to read its manifest whenever the Integrations page -/// opens, so a group- or world-writable file at the expected path would let a -/// second account choose the code Maple runs. Ownership is deliberately not -/// checked: a normal drag-install leaves the application owned by the user who -/// installed it. -#[cfg(target_os = "macos")] -fn ensure_standalone_binary_is_protected(path: &Path) -> Result<(), String> { - use std::os::unix::fs::PermissionsExt; - - let metadata = std::fs::metadata(path) - .map_err(|error| format!("could not inspect the standalone application: {error}"))?; - if metadata.permissions().mode() & 0o022 != 0 { - return Err( - "the standalone application is writable by other accounts and was not run".to_string(), - ); - } - Ok(()) -} - -#[cfg(target_os = "macos")] -async fn probe_cua_manifest(path: &Path) -> Result<(String, AgentMcpServer), String> { - let canonical = path - .canonicalize() - .map_err(|error| format!("could not resolve the executable: {error}"))?; - let mut command = tokio::process::Command::new(&canonical); - command - .arg("manifest") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true); - let mut child = command - .spawn() - .map_err(|error| format!("could not start the manifest probe: {error}"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| "could not capture the manifest".to_string())?; - let mut reader = tokio::spawn(super::bounded_process::read_bounded_stdout( - stdout, - MAX_CUA_MANIFEST_BYTES, - "the Cua Driver manifest", - )); - - let status = match tokio::time::timeout(CUA_PROBE_TIMEOUT, child.wait()).await { - Ok(Ok(status)) => status, - Ok(Err(error)) => { - reader.abort(); - return Err(format!("could not wait for the manifest probe: {error}")); - } - Err(_) => { - reader.abort(); - let _ = child.kill().await; - let _ = child.wait().await; - return Err("manifest probe timed out".to_string()); } - }; - let bytes = match tokio::time::timeout(CUA_PROBE_TIMEOUT, &mut reader).await { - Ok(Ok(result)) => result?, - Ok(Err(error)) => return Err(format!("could not collect the manifest: {error}")), - Err(_) => { - reader.abort(); - return Err("manifest output did not close".to_string()); - } - }; - if !status.success() { - return Err(format!("manifest probe exited with {status}")); } - parse_cua_manifest(&canonical, &bytes) -} - -#[cfg(any(target_os = "macos", test))] -fn parse_cua_manifest( - detected_binary: &Path, - bytes: &[u8], -) -> Result<(String, AgentMcpServer), String> { - let manifest: CuaManifest = serde_json::from_slice(bytes) - .map_err(|error| format!("manifest is not valid JSON: {error}"))?; - if manifest.schema_version != CUA_MANIFEST_SCHEMA_VERSION { - return Err(format!( - "manifest schema {} is not supported", - manifest.schema_version - )); - } - let version = manifest.binary_version.trim(); - if version.is_empty() { - return Err("manifest has no binary version".to_string()); - } - if manifest.mcp_invocation.args != ["mcp"] { - return Err("manifest does not advertise the expected MCP entrypoint".to_string()); - } - - let declared_binary = canonical_manifest_path(&manifest.binary_path, "binary path")?; - let command_binary = canonical_manifest_path(&manifest.mcp_invocation.command, "MCP command")?; - if declared_binary != detected_binary || command_binary != detected_binary { - return Err("manifest executable does not match the detected application".to_string()); - } - - let verified_binary = detected_binary - .to_str() - .ok_or_else(|| "detected Cua Driver path is not valid UTF-8".to_string())?; - let command = join_mcp_command(std::iter::once(verified_binary).chain(["mcp"]))?; - let server = AgentMcpServer { - name: CUA_DRIVER_MCP_NAME.to_string(), - description: CUA_EXTERNAL_MCP_DESCRIPTION.to_string(), - enabled: false, - timeout_seconds: DEFAULT_MCP_TIMEOUT_SECONDS, - transport: AgentMcpTransport::Stdio { - command, - environment: Vec::new(), - }, - }; - validate_stored_cua_server(&server)?; - Ok((version.to_string(), server)) -} - -#[cfg(any(target_os = "macos", test))] -fn canonical_manifest_path(path: &str, label: &str) -> Result { - let path = Path::new(path); - if !path.is_absolute() { - return Err(format!("manifest {label} is not absolute")); - } - path.canonicalize() - .map_err(|error| format!("could not resolve manifest {label}: {error}")) -} - -#[cfg(any(target_os = "macos", test))] -fn join_mcp_command<'a>(parts: impl IntoIterator) -> Result { - parts - .into_iter() - .map(|part| { - if part.is_empty() || part.contains(['\0', '"']) { - return Err("manifest MCP arguments contain unsupported characters".to_string()); - } - if part.chars().any(char::is_whitespace) { - Ok(format!("\"{part}\"")) - } else { - Ok(part.to_string()) - } - }) - .collect::, _>>() - .map(|parts| parts.join(" ")) } #[cfg(test)] @@ -1331,35 +980,6 @@ mod tests { } } - fn stored_cua_server(root: &Path, enabled: bool) -> AgentMcpServer { - let binary = root.join("cua-driver"); - let command = join_mcp_command([binary.to_str().expect("UTF-8 test path"), "mcp"]) - .expect("valid test command"); - AgentMcpServer { - name: CUA_DRIVER_MCP_NAME.to_string(), - description: CUA_EXTERNAL_MCP_DESCRIPTION.to_string(), - enabled, - timeout_seconds: DEFAULT_MCP_TIMEOUT_SECONDS, - transport: AgentMcpTransport::Stdio { - command, - environment: Vec::new(), - }, - } - } - - fn manifest(binary: &Path, schema: &str, args: &[&str]) -> Vec { - serde_json::to_vec(&json!({ - "schema_version": schema, - "binary_path": binary, - "binary_version": "0.21.1-test", - "mcp_invocation": { - "command": binary, - "args": args, - } - })) - .unwrap() - } - fn codex_registry(enabled: bool) -> StoredIntegrationRegistry { provider_registry("codex", enabled) } @@ -1370,8 +990,7 @@ mod tests { integrations: vec![StoredIntegration { id: provider.to_string(), enabled, - backend: AgentIntegrationBackend::Embedded, - external_server: None, + backend: StoredBackend::Embedded, }], } } @@ -1469,9 +1088,8 @@ mod tests { rows.iter() .any(|row| row.name == CUA_DRIVER_MCP_NAME && !row.enabled && !row.available) ); - let temp = tempfile::tempdir().unwrap(); - let mut custom = stored_cua_server(temp.path(), false); - custom.name = "codex".to_string(); + let mut custom = http_server("codex"); + custom.enabled = false; let rows = project_session_mcp_servers(&codex_registry(true), &[custom], &session).unwrap(); let codex_rows = rows .iter() @@ -1697,75 +1315,6 @@ mod tests { let _ = fs::remove_dir_all(&fixture.root); } - #[test] - fn manifest_builds_an_ordinary_disabled_mcp_server() { - let temporary = tempfile::tempdir().unwrap(); - let binary = temporary.path().join("cua driver"); - fs::write(&binary, b"fixture").unwrap(); - let binary = binary.canonicalize().unwrap(); - - let (version, server) = - parse_cua_manifest(&binary, &manifest(&binary, "1", &["mcp"])).unwrap(); - - assert_eq!(version, "0.21.1-test"); - assert!(!server.enabled); - assert_eq!(server.name, CUA_DRIVER_MCP_NAME); - assert_eq!( - server.transport, - AgentMcpTransport::Stdio { - command: format!("\"{}\" mcp", binary.display()), - environment: Vec::new(), - } - ); - } - - #[test] - fn manifest_rejects_unknown_schema_and_non_mcp_invocation() { - let temporary = tempfile::tempdir().unwrap(); - let binary = temporary.path().join("cua-driver"); - fs::write(&binary, b"fixture").unwrap(); - let binary = binary.canonicalize().unwrap(); - - assert!( - parse_cua_manifest(&binary, &manifest(&binary, "2", &["mcp"])) - .unwrap_err() - .contains("schema") - ); - assert!( - parse_cua_manifest(&binary, &manifest(&binary, "1", &["serve"])) - .unwrap_err() - .contains("MCP entrypoint") - ); - } - - #[test] - fn manifest_rejects_a_different_or_relative_binary() { - let temporary = tempfile::tempdir().unwrap(); - let detected = temporary.path().join("cua-driver"); - let different = temporary.path().join("different"); - fs::write(&detected, b"fixture").unwrap(); - fs::write(&different, b"fixture").unwrap(); - let detected = detected.canonicalize().unwrap(); - let different = different.canonicalize().unwrap(); - - assert!( - parse_cua_manifest(&detected, &manifest(&different, "1", &["mcp"])) - .unwrap_err() - .contains("does not match") - ); - let relative = br#"{ - "schema_version":"1", - "binary_path":"cua-driver", - "binary_version":"test", - "mcp_invocation":{"command":"cua-driver","args":["mcp"]} - }"#; - assert!( - parse_cua_manifest(&detected, relative) - .unwrap_err() - .contains("not absolute") - ); - } - #[test] fn stored_integrations_are_device_local_and_join_the_effective_registry() { let temporary = tempfile::tempdir().unwrap(); @@ -1778,7 +1327,6 @@ mod tests { temporary.path().join("second-device"), ); let user = "cua-device-local@example.com"; - let server = stored_cua_server(temporary.path(), true); save_stored_integrations( &first, user, @@ -1787,22 +1335,25 @@ mod tests { integrations: vec![StoredIntegration { id: CUA_DRIVER_INTEGRATION_ID.to_string(), enabled: true, - backend: AgentIntegrationBackend::External, - external_server: Some(server.clone()), + backend: StoredBackend::Embedded, }], }, ) .unwrap(); + let first_device = stored_integrations_for_read(&first, user); + let entry = first_device + .cua() + .expect("the first device stored a choice"); + assert!(entry.enabled); + assert_eq!(entry.backend, StoredBackend::Embedded); + // A custom server under the integration's key is shadowed on the + // device that stored the choice. assert_eq!( - effective_mcp_servers(&stored_integrations_for_read(&first, user), Vec::new()).unwrap(), - vec![server] - ); - assert!( - effective_mcp_servers(&stored_integrations_for_read(&second, user), Vec::new()) - .unwrap() - .is_empty() + effective_mcp_servers(&first_device, vec![http_server("cua-driver")]).unwrap(), + Vec::new() ); + assert!(stored_integrations_for_read(&second, user).cua().is_none()); } #[test] @@ -1888,35 +1439,6 @@ mod tests { ); } - #[test] - fn the_integration_shadows_a_custom_server_that_shares_its_key() { - let temporary = tempfile::tempdir().unwrap(); - let managed = stored_cua_server(temporary.path(), true); - let stored = StoredIntegrationRegistry { - version: INTEGRATIONS_FILE_VERSION, - integrations: vec![StoredIntegration { - id: CUA_DRIVER_INTEGRATION_ID.to_string(), - enabled: true, - backend: AgentIntegrationBackend::External, - external_server: Some(managed.clone()), - }], - }; - - // A legacy custom server under the same key is replaced rather than - // merged: merging would make normalization reject the whole account. - let effective = effective_mcp_servers( - &stored, - vec![http_server("cua-driver"), http_server("Docs")], - ) - .unwrap(); - assert_eq!(effective.len(), 2); - assert!(effective.iter().any(|server| server.name == "Docs")); - assert!(effective.iter().any(|server| { - server.name == CUA_DRIVER_MCP_NAME - && matches!(server.transport, AgentMcpTransport::Stdio { .. }) - })); - } - #[test] fn embedded_integration_shadows_every_legacy_cua_alias() { let stored = StoredIntegrationRegistry { @@ -1924,8 +1446,7 @@ mod tests { integrations: vec![StoredIntegration { id: CUA_DRIVER_INTEGRATION_ID.to_string(), enabled: true, - backend: AgentIntegrationBackend::Embedded, - external_server: None, + backend: StoredBackend::Embedded, }], }; @@ -1972,14 +1493,11 @@ mod tests { }, ) .unwrap(); - let managed = stored_cua_server(temporary.path(), false); let detection = CuaDetection { availability: AgentIntegrationAvailability::Available, setup_available: true, - permissions: None, - standalone_version: Some("test".to_string()), + permissions: Some(AgentIntegrationPermissions::none_required()), detail: None, - external_server: Some(managed), }; let enabled = set_integration_default( @@ -1993,14 +1511,15 @@ mod tests { ) .unwrap(); assert!(enabled[0].enabled_for_new_tasks); + assert_eq!(enabled[0].backend, Some(AgentIntegrationBackend::Embedded)); + // Embedded CUA is Maple metadata, not an MCP server: the custom list + // is untouched. let effective = effective_mcp_servers( &stored_integrations_for_read(&paths, user), load_agent_config_inner(&paths, user).unwrap().mcp_servers, ) .unwrap(); - assert_eq!(effective.len(), 2); - assert_eq!(effective[0], custom); - assert!(effective[1].enabled); + assert_eq!(effective, vec![custom.clone()]); let disabled = set_integration_default( &paths, @@ -2013,25 +1532,32 @@ mod tests { ) .unwrap(); assert!(!disabled[0].enabled_for_new_tasks); + assert!( + stored_integrations_for_read(&paths, user) + .cua() + .is_some_and(|entry| !entry.enabled), + "disabling keeps the managed entry, switched off" + ); let effective = effective_mcp_servers( &stored_integrations_for_read(&paths, user), load_agent_config_inner(&paths, user).unwrap().mcp_servers, ) .unwrap(); - assert_eq!(effective.len(), 2); - assert_eq!(effective[0], custom); - assert!(!effective[1].enabled); + assert_eq!(effective, vec![custom]); } + /// Files from builds that offered the standalone CuaDriver still load: + /// a version-1 file is unsupported and read as empty, and a version-2 + /// entry that selected the `external` backend is dropped while the other + /// entries survive. #[test] - fn version_one_registry_migrates_to_the_external_backend_without_switching() { + fn registries_that_name_the_retired_standalone_backend_still_load() { let temporary = tempfile::tempdir().unwrap(); let paths = AgentPathLayout::from_app_roots( temporary.path().join("config"), temporary.path().join("local-data"), ); - let user = "cua-migration@example.com"; - let server = stored_cua_server(temporary.path(), true); + let user = "cua-retired@example.com"; let path = integrations_path(&paths, user).unwrap(); write_device_local_json_file( &path, @@ -2039,21 +1565,33 @@ mod tests { "version": 1, "integrations": [{ "id": CUA_DRIVER_INTEGRATION_ID, - "server": server, + "server": http_server("cua-driver"), }], }), ) .unwrap(); + assert!(load_stored_integrations(&paths, user).is_err()); + assert!(stored_integrations_for_read(&paths, user).cua().is_none()); - let migrated = load_stored_integrations(&paths, user).unwrap(); - assert_eq!(migrated.version, INTEGRATIONS_FILE_VERSION); - assert_eq!(migrated.integrations.len(), 1); - assert!(migrated.integrations[0].enabled); - assert_eq!( - migrated.integrations[0].backend, - AgentIntegrationBackend::External - ); - assert!(migrated.integrations[0].external_server.is_some()); + write_device_local_json_file( + &path, + &json!({ + "version": INTEGRATIONS_FILE_VERSION, + "integrations": [ + { + "id": CUA_DRIVER_INTEGRATION_ID, + "enabled": true, + "backend": "external", + "externalServer": http_server("cua-driver"), + }, + { "id": "codex", "enabled": true, "backend": "embedded" } + ], + }), + ) + .unwrap(); + let loaded = load_stored_integrations(&paths, user).unwrap(); + assert!(loaded.cua().is_none(), "the retired entry is dropped"); + assert!(external_agent_enabled(&loaded, "codex")); } #[test] @@ -2064,7 +1602,6 @@ mod tests { temporary.path().join("local-data"), ); let user = "cua-embedded@example.com"; - let external = stored_cua_server(temporary.path(), true); save_stored_integrations( &paths, user, @@ -2073,8 +1610,7 @@ mod tests { integrations: vec![StoredIntegration { id: CUA_DRIVER_INTEGRATION_ID.to_string(), enabled: true, - backend: AgentIntegrationBackend::External, - external_server: Some(external), + backend: StoredBackend::Embedded, }], }, ) @@ -2083,9 +1619,7 @@ mod tests { availability: AgentIntegrationAvailability::Available, setup_available: true, permissions: Some(AgentIntegrationPermissions::none_required()), - standalone_version: Some("test".to_string()), detail: None, - external_server: None, }; let projected = 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 820f41fe9..87a11584e 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -134,9 +134,6 @@ pub struct AgentIntegration { pub backend: Option, /// Version of the implementation built into Maple, when one exists. pub version: Option, - /// Version of a separately-installed compatible application, when one was - /// discovered. Its presence never grants Maple permission or enables it. - pub standalone_version: Option, /// Host-process permissions needed by the built-in implementation. pub permissions: Option, /// Whether a setup action would still do something. It is false once the @@ -150,11 +147,13 @@ pub struct AgentIntegration { pub detail: Option, } +/// The implementation behind a curated integration. Only Maple's own +/// embedded backend remains; tasks saved with the retired standalone driver +/// read as having no backend. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentIntegrationBackend { Embedded, - External, } /// One host-process permission that a built-in integration needs. diff --git a/apps/maple-agent/docs/embedded-cua.md b/apps/maple-agent/docs/embedded-cua.md index 1925a3373..cce1391b9 100644 --- a/apps/maple-agent/docs/embedded-cua.md +++ b/apps/maple-agent/docs/embedded-cua.md @@ -61,8 +61,8 @@ Helper usage is recorded outside the primary context ledger. The native client is ephemeral. Goose must not serialize a Rust object as an ordinary MCP transport, so its extension snapshot excludes the client. Maple -stores only versioned logical task metadata: whether CUA is enabled and whether -that task uses the embedded or legacy external backend. On cold load or a new +stores only versioned logical task metadata: whether CUA is enabled for the +task, and the embedded backend it uses. On cold load or a new turn Maple recreates its trusted connection and rejoins the stable account/task CUA lifecycle. @@ -148,14 +148,12 @@ action in a session raises a consent prompt. Background delivery is unavailable on GNOME, so actions use `delivery_mode: "foreground"`, which activates the target window and restores the previous one afterwards. -Maple detects a compatible standalone `CuaDriver.app` on macOS only, for -migration from the first integrations preview. It refuses to run that -executable when other accounts can write to it. No other platform looks for a -separately installed driver, so no foreign binary is ever executed. Existing tasks and version-1 device -settings retain that external backend. A successful explicit setup switches -the default for future tasks to embedded CUA without rewriting historical task -snapshots. Maple never installs, updates, launches, or reconfigures the -standalone driver. +Maple no longer detects or runs a separately installed `CuaDriver.app`, so no +foreign binary is ever executed. A task saved by the first integrations preview +with that driver's stdio entry loses the entry before any agent is built for +it, and a device setting that selected the external backend is ignored on +load; both read as "not set up" until the user enables built-in CUA. Nothing +is migrated or rewritten beyond that. Enabling the card changes the default for newly-created tasks. Existing tasks retain an independent switch in the composer. Changing tools while a task is From 3f41615ed55a8174e01b5b3bade485c6098a22bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:20:24 +0000 Subject: [PATCH 2/2] maple-agent: drop docs that still describe the standalone CuaDriver The external-agents doc still promised that an old standalone-driver task keeps its backend, and the README still listed launch details in integrations.json. Neither exists after the standalone backend went. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d --- apps/maple-agent/README.md | 2 +- apps/maple-agent/docs/external-agents.md | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 45b88888a..caf04a752 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -462,7 +462,7 @@ shows unpackaged builds; packaged Dev and Prod append `maple-agent-dev` and | `/agent/accounts//goose/config/skills/` | Skills the account's tasks can load, including the delegation skills Maple installs while any external agent is enabled. | | `/agent/goose-runtime/` | Goose process configuration. | | `/auth.json` | Sign-in credentials (mode 0600). Device-local; never in a roaming profile. | -| `/agent/accounts//integrations.json` | Per-account defaults and validated launch details for integrations detected on this device. | +| `/agent/accounts//integrations.json` | Per-account defaults for the integrations on this device. | | `/agent/accounts//goose/data/sessions/sessions.db` | Goose session history and usage ledger (SQLite, WAL). | | `/agent/accounts//tool_summaries.db` | Model-written one-line summaries of tool calls (SQLite, WAL). | | `/agent/accounts//attachments/` | Image attachments. | diff --git a/apps/maple-agent/docs/external-agents.md b/apps/maple-agent/docs/external-agents.md index 54bb7a646..45d80842b 100644 --- a/apps/maple-agent/docs/external-agents.md +++ b/apps/maple-agent/docs/external-agents.md @@ -196,9 +196,8 @@ No provider-specific composer branch or database migration is needed. Task overrides live in the versioned `maple_integrations` extension data, keyed by provider ID. Missing entries mean disabled. A true entry grants access -only while Settings also enables that provider. CUA -keeps its existing `maple_cua` backend metadata so an old external driver -task cannot silently switch to the embedded backend. +only while Settings also enables that provider. CUA keeps its per-task state +in its own `maple_cua` metadata. The selector carries a typed `kind` alongside `name` and `displayName`. MCP names and external provider IDs are separate domains; a custom MCP