From 4e352b85ae3881e9e8e9e46ff1197c4c1540906d Mon Sep 17 00:00:00 2001 From: Xun Lu Date: Mon, 2 Nov 2026 21:53:10 +0800 Subject: [PATCH 1/6] fix(computer-use): report missing macOS permissions before native work Native Computer Use on macOS needs Accessibility and Screen Recording, which macOS grants to the application that started the Host. Without them the first AX call failed with an opaque -25211 deep inside a tool. Check both grants before native work, ask macOS to show its request the first time one is missing, and fail with a single permission_required: line naming the application to allow. The check is a real call (an AX query to this user's Dock, a ScreenCaptureKit listing), because AXIsProcessTrusted and CGPreflightScreenCaptureAccess keep their first answer in a Host without a run loop; a grant the user turns on is seen by the next call without restarting the Host. A confirmed grant is remembered, and failures that a missing grant explains (AX -25211, a refused ScreenCaptureKit listing) check again. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + platform/computer-use/Cargo.toml | 2 + platform/computer-use/src/macos.rs | 3 +- platform/computer-use/src/macos/tcc.rs | 374 +++++++++++++++++++++++++ platform/computer-use/src/session.rs | 11 +- 5 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 platform/computer-use/src/macos/tcc.rs diff --git a/Cargo.lock b/Cargo.lock index 5ac973f3329..5344ab887d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7694,6 +7694,7 @@ dependencies = [ "regex", "reqwest", "schemars 1.2.3", + "screencapturekit 8.0.1", "serde", "serde_json", "sha2 0.11.1", diff --git a/platform/computer-use/Cargo.toml b/platform/computer-use/Cargo.toml index cb3d5392b98..83ea06700d5 100644 --- a/platform/computer-use/Cargo.toml +++ b/platform/computer-use/Cargo.toml @@ -40,6 +40,7 @@ native-desktop = [ "dep:windows-sys", "dep:platform-linux", "dep:platform-macos", + "dep:screencapturekit", "dep:core-foundation", "dep:objc2", "dep:objc2-foundation", @@ -108,6 +109,7 @@ imagesize.workspace = true [target.'cfg(target_os = "macos")'.dependencies] unicode-segmentation = { workspace = true, optional = true } platform-macos = { git = "https://github.com/maka-agent/cua", rev = "b97a7e7a97b1dabc669b7c362ba39691766df761", optional = true } +screencapturekit = { version = "8.0.1", features = ["macos_15_0"], optional = true } core-foundation = { version = "0.10.1", optional = true } objc2 = { version = "0.6.5", optional = true } objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSData", "NSString"], optional = true } diff --git a/platform/computer-use/src/macos.rs b/platform/computer-use/src/macos.rs index bc88efb803a..7bf46c2b70f 100644 --- a/platform/computer-use/src/macos.rs +++ b/platform/computer-use/src/macos.rs @@ -26,6 +26,7 @@ pub(crate) mod mouse; pub(crate) mod page_scroll; pub(crate) mod pasteboard; pub(crate) mod permission_subject; +pub(crate) mod tcc; use crate::protocol::*; use core_foundation::{ base::{CFEqual, CFGetTypeID, CFRange, CFRelease, TCFType}, @@ -79,7 +80,7 @@ pub(crate) fn require_desktop() -> Result<(), ToolError> { )); } } - Ok(()) + tcc::require(tcc::Grant::Accessibility) } pub(crate) struct Target { diff --git a/platform/computer-use/src/macos/tcc.rs b/platform/computer-use/src/macos/tcc.rs new file mode 100644 index 00000000000..81522552a4f --- /dev/null +++ b/platform/computer-use/src/macos/tcc.rs @@ -0,0 +1,374 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//! macOS privacy grants (TCC) that native Computer Use needs. +//! +//! macOS grants Accessibility and Screen Recording to the *responsible* +//! process: the application that started the Host (Maka, or the terminal a +//! CLI ran in), not the Host binary. A missing grant is reported before any +//! native work, naming that application, instead of surfacing later as an +//! opaque AX error. +//! +//! The first time a grant is missing, the Host asks macOS to show its own +//! request. The Host is long-lived, which the request needs: macOS drops it +//! when the asking process exits. A grant takes effect in the running Host +//! as soon as the user turns it on, so the next call simply passes; see +//! [`Grant::observed`] for how that is observed. + +use maka_agent_facts::tools::ToolError; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[derive(Clone, Copy)] +pub(crate) enum Grant { + Accessibility, + ScreenRecording, +} + +impl Grant { + fn code(self) -> &'static str { + match self { + Self::Accessibility => "accessibility", + Self::ScreenRecording => "screen_recording", + } + } + + fn pane(self) -> &'static str { + match self { + Self::Accessibility => "Accessibility", + Self::ScreenRecording => "Screen & System Audio Recording", + } + } + + /// Whether the grant is in effect for this Host now, observed with a + /// real call. + /// + /// `AXIsProcessTrusted` and `CGPreflightScreenCaptureAccess` keep their + /// first answer in a process without a main run loop, such as the Host, + /// while the APIs themselves follow the grant at once. So a real call + /// decides: an AX query that macOS does not refuse with + /// `kAXErrorAPIDisabled`, and a ScreenCaptureKit listing that succeeds. + fn observed(self) -> bool { + match self { + Self::Accessibility => accessibility_enabled(), + Self::ScreenRecording => screencapturekit::prelude::SCShareableContent::get().is_ok(), + } + } + + /// Whether a real call has shown the grant in effect. Later checks trust + /// it, so native work does not pay for the probe each time; a failure + /// that shows the grant revoked clears it (see [`explain`]). + fn confirmed(self) -> &'static AtomicBool { + static ACCESSIBILITY: AtomicBool = AtomicBool::new(false); + static SCREEN_RECORDING: AtomicBool = AtomicBool::new(false); + match self { + Self::Accessibility => &ACCESSIBILITY, + Self::ScreenRecording => &SCREEN_RECORDING, + } + } + + /// Whether this Host has already asked macOS to show the request. + fn asked(self) -> &'static AtomicBool { + static ACCESSIBILITY: AtomicBool = AtomicBool::new(false); + static SCREEN_RECORDING: AtomicBool = AtomicBool::new(false); + match self { + Self::Accessibility => &ACCESSIBILITY, + Self::ScreenRecording => &SCREEN_RECORDING, + } + } + + fn request(self) { + use platform_macos::permissions::status; + match self { + Self::Accessibility => { + status::request_accessibility(); + } + Self::ScreenRecording => { + status::request_screen_recording(); + // macOS shows the Screen Recording request at most once; listing + // shareable content is what enters the application in the + // System Settings list afterwards. Its failure is the expected + // answer while the grant is missing. + let _ = screencapturekit::prelude::SCShareableContent::get(); + } + } + } +} + +/// Asks the Dock, which always runs, for its role. Without the grant macOS +/// refuses with `kAXErrorAPIDisabled`; any other answer means it let the +/// call through. (The system-wide element is no use here: it answers +/// `kAXErrorCannotComplete` either way.) +fn accessibility_enabled() -> bool { + use core_foundation::{ + base::{CFRelease, TCFType}, + string::CFString, + }; + use platform_macos::ax::bindings::{ + AXUIElementCopyAttributeValue, AXUIElementCreateApplication, + AXUIElementSetMessagingTimeout, kAXErrorAPIDisabled, + }; + let Some(pid) = process_with_path(DOCK).or_else(|| process_with_path(FINDER)) else { + return platform_macos::permissions::status::accessibility_granted(); + }; + let attribute = CFString::new("AXRole"); + // SAFETY: the application element and any copied value are owned here + // and released before returning. + unsafe { + let application = AXUIElementCreateApplication(pid); + if application.is_null() { + return platform_macos::permissions::status::accessibility_granted(); + } + // A hung or restarting Dock must not hold native work for the + // default AX timeout. + AXUIElementSetMessagingTimeout(application, 0.5); + let mut value = std::ptr::null(); + let status = + AXUIElementCopyAttributeValue(application, attribute.as_concrete_TypeRef(), &mut value); + if !value.is_null() { + CFRelease(value); + } + CFRelease(application.cast()); + status != kAXErrorAPIDisabled + } +} + +const DOCK: &str = "/System/Library/CoreServices/Dock.app/Contents/MacOS/Dock"; +const FINDER: &str = "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder"; + +/// The pid of a process of this user running this executable. Each +/// logged-in user has their own Dock, and another session's would not +/// answer for this one. +fn process_with_path(executable: &str) -> Option { + // SAFETY: getuid cannot fail. + let user = unsafe { libc::getuid() }; + // SAFETY: a null buffer asks for the count; the second call writes at + // most the buffer's size in bytes. + let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 { + return None; + } + let mut pids = vec![0 as libc::pid_t; count as usize + 64]; + let bytes = (pids.len() * std::mem::size_of::()) as libc::c_int; + // SAFETY: as above. + let count = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) }; + if count <= 0 { + return None; + } + pids.truncate(count as usize); + pids.into_iter() + .filter(|pid| *pid > 0 && owner_of(*pid) == Some(user)) + .find(|pid| path_of(*pid).is_some_and(|path| path == executable)) +} + +fn owner_of(pid: libc::pid_t) -> Option { + let mut info = std::mem::MaybeUninit::::zeroed(); + let size = std::mem::size_of::() as libc::c_int; + // SAFETY: the buffer holds one proc_bsdinfo, whose size is passed; the + // value is read only when the call filled all of it. + unsafe { + let written = libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + size, + ); + (written == size).then(|| info.assume_init().pbi_uid) + } +} + +fn path_of(pid: libc::pid_t) -> Option { + let mut buffer = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: the buffer is writable for the length passed. + let length = + unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) }; + if length <= 0 { + return None; + } + buffer.truncate(length as usize); + String::from_utf8(buffer).ok() +} + +pub(crate) fn require(grant: Grant) -> Result<(), ToolError> { + if grant.confirmed().load(Ordering::Relaxed) { + return Ok(()); + } + if grant.observed() { + grant.confirmed().store(true, Ordering::Relaxed); + return Ok(()); + } + let asked = !grant.asked().swap(true, Ordering::Relaxed); + if asked { + grant.request(); + } + Err(ToolError::Failed(message( + grant, + &responsible_application(), + asked, + ))) +} + +/// An ordinary failure that a revoked grant can cause is checked again with +/// a real call and, when the grant is gone, reported as the missing grant. +/// Other errors, including unconfirmed effects and cleanup, keep their +/// meaning. +pub(crate) fn explain(error: ToolError) -> ToolError { + let Some(grant) = suspect(&error) else { + return error; + }; + grant.confirmed().store(false, Ordering::Relaxed); + match require(grant) { + Err(missing) => missing, + Ok(()) => error, + } +} + +/// The grant whose absence this failure can be a symptom of. +fn suspect(error: &ToolError) -> Option { + let ToolError::Failed(text) = error else { + return None; + }; + if text.contains("AX error -25211") || text.contains("(-25211)") { + Some(Grant::Accessibility) + } else if text.contains("SCShareableContent::get failed") { + Some(Grant::ScreenRecording) + } else { + None + } +} + +/// The tool error for a missing grant. `permission_required:` is the stable +/// prefix; the rest tells the user which switch to turn on. +fn message(grant: Grant, application: &str, asked: bool) -> String { + let request = if asked { + "macOS is showing its permission request now. " + } else { + "" + }; + format!( + "permission_required: {code}; {request}Turn on \"{application}\" in System Settings > \ + Privacy & Security > {pane}, then retry. If it is not listed, reopen System Settings, \ + or add it with the + button.", + code = grant.code(), + pane = grant.pane(), + ) +} + +/// The name macOS shows for the process that holds this Host's grants. +fn responsible_application() -> String { + responsible_path() + .map(|path| application_name(&path)) + .unwrap_or_else(|| "the application that started Maka".into()) +} + +fn responsible_path() -> Option { + type Responsible = unsafe extern "C" fn(libc::pid_t) -> libc::pid_t; + // SAFETY: a private libSystem function with this C signature; it is + // looked up by name and its absence is handled. + let pid = unsafe { + let symbol = libc::dlsym( + libc::RTLD_DEFAULT, + c"responsibility_get_pid_responsible_for_pid".as_ptr(), + ); + if symbol.is_null() { + return None; + } + std::mem::transmute::<*mut libc::c_void, Responsible>(symbol)(libc::getpid()) + }; + if pid <= 0 { + return None; + } + path_of(pid).map(Into::into) +} + +/// The name of the `.app` bundle the executable belongs to (the innermost, +/// as System Settings lists a nested helper under its own name), or the +/// executable's file name. +fn application_name(path: &std::path::Path) -> String { + path.ancestors() + .find(|ancestor| { + ancestor + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("app")) + }) + .or(Some(path)) + .and_then(|path| path.file_stem()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "the application that started Maka".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn names_the_innermost_application_bundle() { + assert_eq!( + application_name(Path::new( + "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal" + )), + "Terminal" + ); + assert_eq!( + application_name(Path::new( + "/Applications/Maka.app/Contents/Helpers/Maka Host.app/Contents/MacOS/launcher" + )), + "Maka Host" + ); + assert_eq!(application_name(Path::new("/usr/local/bin/maka")), "maka"); + } + + #[test] + fn finds_a_running_process_by_its_executable() { + let own = path_of(std::process::id() as libc::pid_t).expect("own path"); + assert_eq!(process_with_path(&own).map(path_of), Some(Some(own))); + assert_eq!(process_with_path("/nonexistent/executable"), None); + } + + #[test] + fn suspects_only_failures_a_missing_grant_explains() { + let failed = |text: &str| suspect(&ToolError::Failed(text.into())); + assert!(matches!( + failed("native page target AXFocusedWindow failed with AX error -25211"), + Some(Grant::Accessibility) + )); + assert!(matches!( + failed("AX operation refused (-25211)"), + Some(Grant::Accessibility) + )); + assert!(matches!( + failed("SCShareableContent::get failed: user declined"), + Some(Grant::ScreenRecording) + )); + assert!(failed("application has multiple open windows").is_none()); + assert!(suspect(&ToolError::OutcomeUnknown("AX error -25211".into())).is_none()); + } + + #[test] + fn message_names_the_switch_and_keeps_its_prefix() { + let text = message(Grant::Accessibility, "Terminal", true); + assert!(text.starts_with("permission_required: accessibility; macOS is showing")); + assert!(text.contains("Turn on \"Terminal\"")); + assert!(text.contains("Privacy & Security > Accessibility")); + let text = message(Grant::ScreenRecording, "Maka", false); + assert!(text.starts_with("permission_required: screen_recording; Turn on \"Maka\"")); + assert!(text.contains("Screen & System Audio Recording")); + } +} diff --git a/platform/computer-use/src/session.rs b/platform/computer-use/src/session.rs index ffffb1fa7f8..5f693648299 100644 --- a/platform/computer-use/src/session.rs +++ b/platform/computer-use/src/session.rs @@ -352,8 +352,12 @@ impl Session { } #[cfg(not(target_os = "windows"))] { - self.invoke_in_turn(command, invocation, cancellation, language) - .await + let result = self + .invoke_in_turn(command, invocation, cancellation, language) + .await; + #[cfg(target_os = "macos")] + let result = result.map_err(crate::macos::tcc::explain); + result } } @@ -1039,6 +1043,9 @@ impl Session { language: Language, ) -> Result { self.verify(handle, session, cancellation, language).await?; + // A macOS observation always captures the window image. + #[cfg(target_os = "macos")] + crate::macos::tcc::require(crate::macos::tcc::Grant::ScreenRecording)?; let ax = !matches!(kind, ObservationKind::Screenshot); let screenshot = !matches!(kind, ObservationKind::Ax); let capture_image = screenshot || cfg!(target_os = "macos"); From be7f73e5f7ba1be20df00034cd374be797a14145 Mon Sep 17 00:00:00 2001 From: Xun Lu Date: Mon, 2 Nov 2026 23:49:50 +0800 Subject: [PATCH 2/6] feat(desktop): run the macOS Host under its own identity macOS grants privacy permissions such as Accessibility and Screen Recording to the application responsible for a process. The Host belonged to whichever application started it first: Maka, or the terminal a CLI ran in. Users had to find and allow that application, and a grant could follow the wrong one. Maka.app now carries Helpers/Maka Host.app. Its executable starts itself again with responsibility disclaimed and runs the Host, passing stdio through, forwarding termination signals and returning the Host's status. Interactive, activation and launchd starts run the Host through it, so its grants belong to "Maka Host". macOS ties a grant to the bytes of the helper's executable, and each package lives in a directory of its own. The helper is therefore copied once into the account data directory and replaced only when a package carries a higher bundle version. Without a helper, the Host runs directly as before. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 + Cargo.toml | 1 + products/maka/cli/src/deployment.rs | 9 + .../maka/cli/src/deployment/activation.rs | 38 +-- .../maka/cli/src/deployment/host_identity.rs | 255 ++++++++++++++++++ products/maka/cli/src/deployment/local.rs | 8 +- .../maka/cli/src/deployment/service/macos.rs | 44 ++- products/maka/cli/src/distribution.rs | 5 + products/maka/cli/src/distribution/archive.rs | 4 + products/maka/cli/src/distribution/target.rs | 9 + products/maka/host-shell/Cargo.toml | 27 ++ products/maka/host-shell/src/main.rs | 248 +++++++++++++++++ scripts/rust/build-cli.mjs | 66 ++++- scripts/rust/build-cli.test.mjs | 27 ++ scripts/rust/pack-cli.test.mjs | 2 + scripts/rust/run-cli.test.mjs | 1 + 16 files changed, 721 insertions(+), 30 deletions(-) create mode 100644 products/maka/cli/src/deployment/host_identity.rs create mode 100644 products/maka/host-shell/Cargo.toml create mode 100644 products/maka/host-shell/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 5344ab887d8..f98b928de56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7971,6 +7971,13 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "maka-host-shell" +version = "0.2.0" +dependencies = [ + "libc", +] + [[package]] name = "maka-insights" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 42a39da79cc..6cdfe17418c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "products/maka/cli", "plugins/settings/store", "products/maka/desktop-shell", + "products/maka/host-shell", "products/maka/client", "products/maka/terminal-shell", "products/maka/workbench", diff --git a/products/maka/cli/src/deployment.rs b/products/maka/cli/src/deployment.rs index 617a52a0ea1..59e872e827a 100644 --- a/products/maka/cli/src/deployment.rs +++ b/products/maka/cli/src/deployment.rs @@ -23,6 +23,15 @@ mod connect; mod control; mod diagnostics; mod entry; +#[cfg(target_os = "macos")] +mod host_identity; +#[cfg(not(target_os = "macos"))] +mod host_identity { + /// Only macOS runs the Host under an identity of its own. + pub(super) async fn command(executable: &std::path::Path) -> (std::path::PathBuf, Vec) { + (executable.to_owned(), Vec::new()) + } +} mod local; mod logs; mod package; diff --git a/products/maka/cli/src/deployment/activation.rs b/products/maka/cli/src/deployment/activation.rs index b8d1f2b84ed..8c0b02b09e1 100644 --- a/products/maka/cli/src/deployment/activation.rs +++ b/products/maka/cli/src/deployment/activation.rs @@ -251,25 +251,31 @@ pub(super) async fn connect_or_launch( let capture = maka_host_runtime::logs::Source::from_owner(&owner)? .open_capture()?; drop(owner); + let (program, leading) = + super::host_identity::command(&deployment.executable).await; child = Some( detached::spawn( - &deployment.executable, + &program, &[ - "host", - "candidate", - "--root", - deployment - .root_path - .to_str() - .ok_or("State Root must be UTF-8")?, - "--expected-root-id", - &deployment.root_id, - "--startup-attempt-id", - &Uuid::new_v4().to_string(), - "--owner-stdin", - "--initial-connection-timeout-ms", - "30000", - ], + leading.iter().map(String::as_str).collect::>(), + vec![ + "host", + "candidate", + "--root", + deployment + .root_path + .to_str() + .ok_or("State Root must be UTF-8")?, + "--expected-root-id", + &deployment.root_id, + "--startup-attempt-id", + &Uuid::new_v4().to_string(), + "--owner-stdin", + "--initial-connection-timeout-ms", + "30000", + ], + ] + .concat(), Some(capture), ) .await?, diff --git a/products/maka/cli/src/deployment/host_identity.rs b/products/maka/cli/src/deployment/host_identity.rs new file mode 100644 index 00000000000..2df69176081 --- /dev/null +++ b/products/maka/cli/src/deployment/host_identity.rs @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//! The identity the Host runs under on macOS. +//! +//! macOS grants Accessibility and Screen Recording, which Computer Use +//! needs, to the application responsible for a process. Started directly, +//! the Host belongs to whichever application started it first (Maka, or the +//! terminal a CLI ran in). `Maka.app` therefore carries a helper, +//! `Maka Host.app`, whose executable runs the Host as its own application, +//! so the grants belong to "Maka Host" however the Host was started. +//! +//! macOS ties a grant to the exact bytes of that executable, and every +//! package is installed into a directory of its own. So the helper is +//! copied once into the account data directory and replaced only when a +//! package carries a higher bundle version; a new build of the same version +//! keeps the copy, and with it the grants. Bump `hostApp.version` in +//! `scripts/rust/build-cli.mjs` only when the helper must change. + +use std::path::{Path, PathBuf}; + +/// The program and its leading arguments that run `executable` as the Host. +/// Without a usable helper, the executable runs directly. +pub(super) async fn command(executable: &Path) -> (PathBuf, Vec) { + let direct = (executable.to_owned(), Vec::new()); + let Some(argument) = executable.to_str().map(str::to_owned) else { + return direct; + }; + let executable = executable.to_owned(); + match tokio::task::spawn_blocking(move || shell(&executable)).await { + Ok(Some(shell)) => (shell, vec![argument]), + _ => direct, + } +} + +const BUNDLE: &str = "Maka Host.app"; +const EXECUTABLE: &str = "Contents/MacOS/maka-host-shell"; + +/// The installed helper's executable, installing, upgrading or repairing it +/// from the package `executable` belongs to. It blocks on file system work. +pub(super) fn shell(executable: &Path) -> Option { + let installed = maka_event_log::root::RootNamespaces::for_current_account() + .ok()? + .ownership + .parent()? + .join(BUNDLE); + let packaged = executable + .parent()? + .join("Maka.app/Contents/Helpers") + .join(BUNDLE); + prepare(&packaged, &installed) +} + +fn prepare(packaged: &Path, installed: &Path) -> Option { + let shell = installed.join(EXECUTABLE); + let usable = version(installed).is_some() && shell.is_file(); + let newer = version(packaged) > version(installed); + if newer || (!usable && version(packaged).is_some()) { + // A failed install keeps the helper already installed, if any; the + // Host then runs under it or, without one, directly. + let _ = install(packaged, installed); + } + (version(installed).is_some() && shell.is_file()).then_some(shell) +} + +/// The bundle's `CFBundleVersion`, an integer the build sets. +fn version(bundle: &Path) -> Option { + let plist = std::fs::read_to_string(bundle.join("Contents/Info.plist")).ok()?; + let (_, after) = plist.split_once("CFBundleVersion")?; + let (_, value) = after.split_once("")?; + let (value, _) = value.split_once("")?; + value.trim().parse().ok() +} + +/// Copies the bundle byte for byte next to `destination`, then swaps it +/// into place in one step, so a reader sees either the old or the new +/// bundle, never a partial or missing one. +fn install(source: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::unix::ffi::OsStrExt; + + let parent = destination.parent().ok_or(std::io::ErrorKind::NotFound)?; + remove_abandoned(parent); + let staged = parent.join(format!("{STAGED}{}", std::process::id())); + let _ = std::fs::remove_dir_all(&staged); + copy(source, &staged).inspect_err(|_| { + let _ = std::fs::remove_dir_all(&staged); + })?; + let from = std::ffi::CString::new(staged.as_os_str().as_bytes())?; + let to = std::ffi::CString::new(destination.as_os_str().as_bytes())?; + // SAFETY: both are valid NUL-terminated paths. + let swapped = unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_SWAP) }; + let result = if swapped == 0 { + Ok(()) + } else { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::NotFound && !destination.exists() { + // Nothing to swap with: a plain rename never replaces a bundle + // that appeared meanwhile, because it fails on a full directory. + std::fs::rename(&staged, destination) + } else { + Err(error) + } + }; + // After a swap the staged path holds the old bundle. + let _ = std::fs::remove_dir_all(&staged); + result +} + +/// The prefix of a bundle being installed, followed by the installer's pid. +const STAGED: &str = ".Maka Host.app-"; + +/// Removes bundles staged by installers that are no longer running. +fn remove_abandoned(parent: &Path) { + let Ok(entries) = std::fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name + .to_str() + .and_then(|name| name.strip_prefix(STAGED)) + .and_then(|pid| pid.parse::().ok()) + else { + continue; + }; + // SAFETY: signal 0 only checks that the process exists. + let gone = pid > 0 + && unsafe { libc::kill(pid, 0) } != 0 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH); + if gone { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +fn copy(source: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::create_dir(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let kind = entry.file_type()?; + let target = destination.join(entry.file_name()); + if kind.is_dir() { + copy(&entry.path(), &target)?; + } else if kind.is_file() { + // Copies the permissions too. + std::fs::copy(entry.path(), &target)?; + } else { + return Err(std::io::Error::other("helper bundle holds a link")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bundle(root: &Path, name: &str, version: &str, executable: &[u8]) -> PathBuf { + let bundle = root.join(name); + std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + std::fs::write( + bundle.join("Contents/Info.plist"), + format!( + "\n CFBundleVersion\n {version}\n\n" + ), + ) + .unwrap(); + std::fs::write(bundle.join(EXECUTABLE), executable).unwrap(); + bundle + } + + #[test] + fn reads_the_bundle_version() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(version(&bundle(root.path(), "a.app", "3", b"x")), Some(3)); + assert_eq!(version(&bundle(root.path(), "b.app", "1.0", b"x")), None); + assert_eq!(version(&root.path().join("missing.app")), None); + } + + #[test] + fn installs_and_replaces_the_whole_bundle() { + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join(BUNDLE); + install(&bundle(root.path(), "one.app", "1", b"one"), &destination).unwrap(); + assert_eq!(std::fs::read(destination.join(EXECUTABLE)).unwrap(), b"one"); + install(&bundle(root.path(), "two.app", "2", b"two"), &destination).unwrap(); + assert_eq!(std::fs::read(destination.join(EXECUTABLE)).unwrap(), b"two"); + assert_eq!(version(&destination), Some(2)); + let leftovers = std::fs::read_dir(root.path()) + .unwrap() + .filter(|entry| { + entry + .as_ref() + .unwrap() + .file_name() + .to_string_lossy() + .starts_with('.') + }) + .count(); + assert_eq!(leftovers, 0); + } + + #[test] + fn installs_upgrades_and_repairs_but_never_downgrades() { + let root = tempfile::tempdir().unwrap(); + let installed = root.path().join(BUNDLE); + let one = bundle(root.path(), "one.app", "1", b"one"); + let two = bundle(root.path(), "two.app", "2", b"two"); + let shell = prepare(&one, &installed).unwrap(); + assert_eq!(std::fs::read(&shell).unwrap(), b"one"); + prepare(&two, &installed).unwrap(); + assert_eq!(std::fs::read(&shell).unwrap(), b"two"); + // An older package keeps the newer helper and its grants. + prepare(&one, &installed).unwrap(); + assert_eq!(std::fs::read(&shell).unwrap(), b"two"); + // A helper without its executable is replaced. + std::fs::remove_file(&shell).unwrap(); + prepare(&one, &installed).unwrap(); + assert_eq!(std::fs::read(&shell).unwrap(), b"one"); + // Nothing to install and nothing installed. + let missing = root.path().join("missing.app"); + assert_eq!(prepare(&missing, &root.path().join("none.app")), None); + } + + #[test] + fn removes_bundles_abandoned_by_stopped_installers() { + let root = tempfile::tempdir().unwrap(); + let mut child = std::process::Command::new("/usr/bin/true").spawn().unwrap(); + let stopped = child.id(); + child.wait().unwrap(); + let abandoned = root.path().join(format!("{STAGED}{stopped}")); + let running = root.path().join(format!("{STAGED}{}", std::process::id())); + std::fs::create_dir(&abandoned).unwrap(); + std::fs::create_dir(&running).unwrap(); + remove_abandoned(root.path()); + assert!(!abandoned.exists()); + assert!(running.exists()); + } +} diff --git a/products/maka/cli/src/deployment/local.rs b/products/maka/cli/src/deployment/local.rs index fff1c06f3d0..3712ac2090c 100644 --- a/products/maka/cli/src/deployment/local.rs +++ b/products/maka/cli/src/deployment/local.rs @@ -213,9 +213,15 @@ async fn connect_inner( if let Some(generation) = update_attempt.generation().await? { arguments.extend(["--generation", generation]); } + let (program, leading) = super::host_identity::command(launcher).await; + let arguments = leading + .iter() + .map(String::as_str) + .chain(arguments) + .collect::>(); child = Some( detached::spawn( - launcher, + &program, &arguments, Some(capture), ) diff --git a/products/maka/cli/src/deployment/service/macos.rs b/products/maka/cli/src/deployment/service/macos.rs index 0ceebcd195e..352c85f5e3c 100644 --- a/products/maka/cli/src/deployment/service/macos.rs +++ b/products/maka/cli/src/deployment/service/macos.rs @@ -28,7 +28,12 @@ pub(super) struct Service { label: String, target: String, path: PathBuf, - definition: String, + /// The service definition around its program arguments. + definition: (String, String), + arguments: Vec, + /// The Host's executable, run under its own identity; see + /// `host_identity`. + host: Option, capture: Option, } @@ -45,9 +50,8 @@ impl Service { let path = home .join("Library/LaunchAgents") .join(format!("{label}.plist")); - let args = arguments(deployment, role)? - .map(|arg| format!("{}", xml(arg))) - .join(""); + let arguments = arguments(deployment, role)?.map(str::to_owned).to_vec(); + let host = (role == Role::Host).then(|| deployment.executable.clone()); let capture = if role == Role::Host { Some(maka_host_runtime::logs::Source::for_root( &deployment.root_id, @@ -63,11 +67,11 @@ impl Service { let log = xml(log.to_str().ok_or("service log path must be UTF-8")?); let definition = if role == Role::Updater { format!( - "\nLabel{label}ProgramArguments{args}StartInterval600RunAtLoadUmask63StandardErrorPath{log}\n" + "\nLabel{label}ProgramArguments\0StartInterval600RunAtLoadUmask63StandardErrorPath{log}\n" ) } else { format!( - "\nLabel{label}ProgramArguments{args}KeepAliveSuccessfulExitThrottleInterval2ExitTimeOut45Umask63StandardErrorPath{log}\n" + "\nLabel{label}ProgramArguments\0KeepAliveSuccessfulExitThrottleInterval2ExitTimeOut45Umask63StandardErrorPath{log}\n" ) }; Ok(Self { @@ -75,7 +79,12 @@ impl Service { uid, label, path, - definition, + definition: definition + .split_once('\0') + .map(|(head, tail)| (head.to_owned(), tail.to_owned())) + .ok_or("service definition lost its arguments")?, + arguments, + host, capture, }) } @@ -91,7 +100,26 @@ impl Service { if let Some(source) = &self.capture { drop(source.open_capture()?); } - publish(&self.path, &self.definition)?; + // Installing the Host's identity happens here, not when a service + // is only observed, stopped or removed. + let shell = self + .host + .as_deref() + .and_then(super::super::host_identity::shell) + .map(|shell| { + shell + .into_os_string() + .into_string() + .map_err(|_| "Host identity path must be UTF-8") + }) + .transpose()?; + let arguments = shell + .iter() + .chain(&self.arguments) + .map(|argument| format!("{}", xml(argument))) + .collect::(); + let (head, tail) = &self.definition; + publish(&self.path, &format!("{head}{arguments}{tail}"))?; checked(command("/bin/launchctl", &["enable", &self.target])?)?; checked(command( "/bin/launchctl", diff --git a/products/maka/cli/src/distribution.rs b/products/maka/cli/src/distribution.rs index 848f8e99138..147c0947323 100644 --- a/products/maka/cli/src/distribution.rs +++ b/products/maka/cli/src/distribution.rs @@ -591,6 +591,11 @@ mod tests { b"\n", ); append("bin/Maka.app/Contents/Resources/Maka.icns", b"icns"); + append( + "bin/Maka.app/Contents/Helpers/Maka Host.app/Contents/Info.plist", + b"\n", + ); + append(target.host_shell().unwrap(), &binary); for name in ["DISCLAIMER", "LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.txt"] { append( &format!("bin/Maka.app/Contents/Resources/{name}"), diff --git a/products/maka/cli/src/distribution/archive.rs b/products/maka/cli/src/distribution/archive.rs index ca16e673e91..c5cfedda86a 100644 --- a/products/maka/cli/src/distribution/archive.rs +++ b/products/maka/cli/src/distribution/archive.rs @@ -75,6 +75,7 @@ fn required(target: Target) -> impl Iterator { .into_iter() .chain(target.desktop_members().iter().copied()) .chain(target.service_executable()) + .chain(target.host_shell()) } /// The package root or a parent of a required member. @@ -398,6 +399,9 @@ fn validate_package(directory: &Path, target: Target, version: &str) -> Result<( if let Some(service) = target.service_executable() { target.validate_binary(&mut regular_file(&directory.join(service))?, true)?; } + if let Some(shell) = target.host_shell() { + target.validate_binary(&mut regular_file(&directory.join(shell))?, false)?; + } Ok(()) } diff --git a/products/maka/cli/src/distribution/target.rs b/products/maka/cli/src/distribution/target.rs index d3f66f7bf19..fcb4b560b8b 100644 --- a/products/maka/cli/src/distribution/target.rs +++ b/products/maka/cli/src/distribution/target.rs @@ -114,6 +114,7 @@ impl Target { "bin/Maka.app/Contents/Resources/LICENSE", "bin/Maka.app/Contents/Resources/NOTICE", "bin/Maka.app/Contents/Resources/THIRD_PARTY_NOTICES.txt", + "bin/Maka.app/Contents/Helpers/Maka Host.app/Contents/Info.plist", ], _ => &[], } @@ -132,6 +133,14 @@ impl Target { || name == self.python_worker() || name == self.desktop_executable() || Some(name) == self.service_executable() + || Some(name) == self.host_shell() + } + + /// The executable of the identity the Host runs under on macOS; see + /// `deployment/host_identity.rs`. + pub fn host_shell(self) -> Option<&'static str> { + matches!(self, Self::DarwinArm64 | Self::DarwinX64) + .then_some("bin/Maka.app/Contents/Helpers/Maka Host.app/Contents/MacOS/maka-host-shell") } pub fn service_executable(self) -> Option<&'static str> { diff --git a/products/maka/host-shell/Cargo.toml b/products/maka/host-shell/Cargo.toml new file mode 100644 index 00000000000..0dfa26db32f --- /dev/null +++ b/products/maka/host-shell/Cargo.toml @@ -0,0 +1,27 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[package] +name = "maka-host-shell" +description = "The macOS identity the Maka Host runs under for privacy grants." +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +libc.workspace = true diff --git a/products/maka/host-shell/src/main.rs b/products/maka/host-shell/src/main.rs new file mode 100644 index 00000000000..12ffc17c97f --- /dev/null +++ b/products/maka/host-shell/src/main.rs @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//! The executable of `Maka Host.app`, the identity the Maka Host runs under +//! on macOS. +//! +//! macOS grants Accessibility and Screen Recording to the *responsible* +//! process, which is normally the application that started a process: Maka, +//! or the terminal a CLI ran in. Started as `maka-host-shell `, +//! this executable starts itself again with responsibility disclaimed, so +//! that copy is responsible for itself and the grants belong to +//! `Maka Host.app`. That copy runs ` ` and waits for it. +//! +//! Both copies pass stdin, stdout and stderr through, forward termination +//! signals, and exit with the Host's status, so whoever started the shell +//! sees the Host itself. If the shell is killed outright, the Host still +//! ends with its owner: an interactive Host when its owner closes stdin, +//! and a launchd service with the process group launchd removes. +//! +//! The shell starts only a program named `maka` with `host candidate` or +//! `host service-run`. That keeps it from being a general way to borrow the +//! grants, but a process of the same account can still change what `maka` +//! is, so the grants protect no more than the account itself does. macOS ties a grant to the bytes of this executable, +//! so it changes only together with the bundle version (see +//! `products/maka/cli/src/deployment/host_identity.rs`). + +#[cfg(target_os = "macos")] +fn main() { + std::process::exit(macos::run()); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("maka-host-shell runs only on macOS"); + std::process::exit(2); +} + +#[cfg(target_os = "macos")] +mod macos { + use std::ffi::{CString, OsString}; + use std::os::unix::ffi::OsStringExt; + use std::sync::atomic::{AtomicI32, Ordering}; + + /// Set in the copy that runs with responsibility disclaimed. + const DISCLAIMED: &str = "MAKA_HOST_SHELL_DISCLAIMED"; + const SIGNALS: [libc::c_int; 3] = [libc::SIGTERM, libc::SIGINT, libc::SIGHUP]; + + static CHILD: AtomicI32 = AtomicI32::new(0); + + pub fn run() -> i32 { + let arguments: Vec = std::env::args_os().collect(); + if !permitted(&arguments[1.min(arguments.len())..]) { + eprintln!("usage: maka-host-shell host candidate|service-run [arguments...]"); + return 2; + } + // Termination signals stay pending until the child is known, so + // none of them can end the shell and leave the child behind. + let previous = block(); + let disclaimed = std::env::var_os(DISCLAIMED).is_some(); + // SAFETY: removing a variable before any other thread exists. + unsafe { std::env::remove_var(DISCLAIMED) }; + if !disclaimed && let Some(code) = again(&arguments, &previous) { + return code; + } + // Disclaimed, or disclaiming is unavailable: run the Host here. + match spawn(&arguments[1..], false, &previous) { + Ok(child) => wait(child, &previous), + Err(error) => { + eprintln!("maka-host-shell: cannot start {:?}: {error}", arguments[1]); + 127 + } + } + } + + /// Whether `command` runs the Host: `/maka host candidate|service-run ...`. + fn permitted(command: &[OsString]) -> bool { + let [program, host, role, ..] = command else { + return false; + }; + std::path::Path::new(program).file_name() == Some("maka".as_ref()) + && host == "host" + && (role == "candidate" || role == "service-run") + } + + /// Blocks the termination signals, returning the previous mask. + fn block() -> libc::sigset_t { + // SAFETY: both sets are initialised by the calls that fill them. + unsafe { + let mut blocked: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut blocked); + for signal in SIGNALS { + libc::sigaddset(&mut blocked, signal); + } + let mut previous: libc::sigset_t = std::mem::zeroed(); + libc::sigprocmask(libc::SIG_BLOCK, &blocked, &mut previous); + previous + } + } + + /// Runs this executable again, responsible for itself. `None` when that + /// is not possible. + fn again(arguments: &[OsString], previous: &libc::sigset_t) -> Option { + let mut argv = vec![std::env::current_exe().ok()?.into_os_string()]; + argv.extend(arguments[1..].iter().cloned()); + // SAFETY: setting a variable before any other thread exists; the + // spawned copy inherits it, and this one exits after it. + unsafe { std::env::set_var(DISCLAIMED, "1") }; + let spawned = spawn(&argv, true, previous); + // SAFETY: as above. + unsafe { std::env::remove_var(DISCLAIMED) }; + spawned.ok().map(|child| wait(child, previous)) + } + + /// Starts `argv` with the signal mask the shell started with. + fn spawn( + argv: &[OsString], + disclaim: bool, + mask: &libc::sigset_t, + ) -> std::io::Result { + let argv: Vec = argv + .iter() + .map(|argument| CString::new(argument.clone().into_vec())) + .collect::>()?; + let mut pointers: Vec<*mut libc::c_char> = argv + .iter() + .map(|argument| argument.as_ptr().cast_mut()) + .collect(); + pointers.push(std::ptr::null_mut()); + unsafe extern "C" { + static environ: *const *mut libc::c_char; + } + // SAFETY: the attributes are initialised before use and destroyed + // before returning; argv outlives the call; environ is the + // process's own environment. + unsafe { + let mut attributes: libc::posix_spawnattr_t = std::ptr::null_mut(); + check(libc::posix_spawnattr_init(&mut attributes))?; + let result = (|| { + check(libc::posix_spawnattr_setsigmask(&mut attributes, mask))?; + check(libc::posix_spawnattr_setflags( + &mut attributes, + libc::POSIX_SPAWN_SETSIGMASK as libc::c_short, + ))?; + if disclaim { + check(disclaim_responsibility(&mut attributes))?; + } + let mut pid = 0; + check(libc::posix_spawn( + &mut pid, + argv[0].as_ptr(), + std::ptr::null(), + &attributes, + pointers.as_ptr(), + environ, + ))?; + Ok(pid) + })(); + libc::posix_spawnattr_destroy(&mut attributes); + result + } + } + + /// `responsibility_spawnattrs_setdisclaim`, a private libSystem function + /// looked up by name. + unsafe fn disclaim_responsibility(attributes: *mut libc::posix_spawnattr_t) -> libc::c_int { + type Disclaim = + unsafe extern "C" fn(*mut libc::posix_spawnattr_t, libc::c_int) -> libc::c_int; + // SAFETY: the symbol has this C signature; its absence is handled. + unsafe { + let symbol = libc::dlsym( + libc::RTLD_DEFAULT, + c"responsibility_spawnattrs_setdisclaim".as_ptr(), + ); + if symbol.is_null() { + return libc::ENOSYS; + } + std::mem::transmute::<*mut libc::c_void, Disclaim>(symbol)(attributes, 1) + } + } + + fn check(status: libc::c_int) -> std::io::Result<()> { + if status == 0 { + Ok(()) + } else { + Err(std::io::Error::from_raw_os_error(status)) + } + } + + extern "C" fn forward(signal: libc::c_int) { + let child = CHILD.load(Ordering::Relaxed); + if child > 0 { + // SAFETY: kill is async-signal-safe. + unsafe { libc::kill(child, signal) }; + } + } + + /// Waits for the child, forwarding termination signals to it, and + /// returns the status to exit with. Unblocks the signals that arrived + /// meanwhile, so they reach the child. + fn wait(child: libc::pid_t, previous: &libc::sigset_t) -> i32 { + CHILD.store(child, Ordering::Relaxed); + // SAFETY: the handler only calls kill; the action is initialised + // before use. + unsafe { + let mut action: libc::sigaction = std::mem::zeroed(); + action.sa_sigaction = forward as extern "C" fn(libc::c_int) as libc::sighandler_t; + libc::sigemptyset(&mut action.sa_mask); + for signal in SIGNALS { + libc::sigaction(signal, &action, std::ptr::null_mut()); + } + libc::sigprocmask(libc::SIG_SETMASK, previous, std::ptr::null_mut()); + } + let mut status = 0; + loop { + // SAFETY: status is a valid out pointer. + let waited = unsafe { libc::waitpid(child, &mut status, 0) }; + if waited == child { + break; + } + if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + return 1; + } + } + if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else if libc::WIFSIGNALED(status) { + 128 + libc::WTERMSIG(status) + } else { + 1 + } + } +} diff --git a/scripts/rust/build-cli.mjs b/scripts/rust/build-cli.mjs index 2b2120a764f..7616abd800d 100644 --- a/scripts/rust/build-cli.mjs +++ b/scripts/rust/build-cli.mjs @@ -81,6 +81,7 @@ export async function compileCli({ '-p', 'maka-cli', ...(desktop ? ['-p', 'maka-desktop-shell'] : []), + ...(desktop && triple.includes('-apple-darwin') ? ['-p', 'maka-host-shell'] : []), '-p', 'maka-python-runtime', ]; @@ -138,6 +139,15 @@ export function desktopShell(platform) { /** The desktop's identity; the shell gives its window the same application id. */ const desktopApp = { id: 'org.apache.maka.desktop', name: 'Maka' }; +/** + * The identity the Host runs under on macOS, so privacy grants belong to it + * (products/maka/cli/src/deployment/host_identity.rs). macOS ties a grant to + * the bytes of its executable, and an installed helper is replaced only by a + * higher version: bump `version` only when the helper must change. Every + * bump makes users allow "Maka Host" again. + */ +const hostApp = { id: 'org.apache.maka.host', name: 'Maka Host', version: '1' }; + /** `[workspace.package] version`, which every Maka crate takes. */ export function workspaceVersion(manifest) { const section = /^\[workspace\.package\]$([^]*?)(?=^\[|(?![^]))/mu.exec(manifest)?.[1]; @@ -147,8 +157,9 @@ export function workspaceVersion(manifest) { } /** - * Stage `Maka.app`: the shell, which carries the workbench, and the - * Info.plist and icon macOS reads. + * Stage `Maka.app`: the shell, which carries the workbench, the Info.plist + * and icon macOS reads, and the Host's helper `Maka Host.app`, built beside + * the shell. */ export async function stageDesktopBundle(repositoryRoot, shell, destination) { const version = workspaceVersion(await readFile(join(repositoryRoot, 'Cargo.toml'), 'utf8')); @@ -168,6 +179,7 @@ export async function stageDesktopBundle(repositoryRoot, shell, destination) { const executable = join(contents, 'MacOS', 'maka-desktop-shell'); await copyFile(shell, executable, constants.COPYFILE_FICLONE); await chmod(executable, 0o755); + await stageHostBundle(join(dirname(shell), 'maka-host-shell'), contents); await chmod(stage, 0o755); await rm(destination, { recursive: true, force: true }); await rename(stage, destination); @@ -177,7 +189,46 @@ export async function stageDesktopBundle(repositoryRoot, shell, destination) { } } -function infoPlist({ id, name, version }) { +/** + * `Contents/Helpers/Maka Host.app`. It carries no build-specific value, and + * no signature beyond the linker's: macOS registers it by bundle id and ties + * the grant to the executable's code hash. + */ +async function stageHostBundle(hostShell, contents) { + const metadata = await lstat(hostShell); + if (!metadata.isFile() || metadata.size === 0) { + throw new Error(`Host shell is not a regular file: ${hostShell}`); + } + const host = join(contents, 'Helpers', `${hostApp.name}.app`, 'Contents'); + await mkdir(join(host, 'MacOS'), { recursive: true }); + await writeFile( + join(host, 'Info.plist'), + infoPlist({ + ...hostApp, + executable: 'maka-host-shell', + icon: undefined, + extra: [ + ['LSUIElement', true], + [ + 'NSAccessibilityUsageDescription', + 'Maka Host controls apps on your behalf when you use Computer Use.', + ], + ], + }), + ); + const executable = join(host, 'MacOS', 'maka-host-shell'); + await copyFile(hostShell, executable, constants.COPYFILE_FICLONE); + await chmod(executable, 0o755); +} + +function infoPlist({ + id, + name, + version, + executable = 'maka-desktop-shell', + icon = 'Maka', + extra = [], +}) { // `CFBundleVersion` takes up to three integers, so a pre-release or build // suffix stays in the short version only. const build = /^[0-9]+\.[0-9]+\.[0-9]+/u.exec(version)?.[0] ?? version; @@ -195,8 +246,8 @@ function infoPlist({ id, name, version }) { ...[ ['CFBundleDevelopmentRegion', 'en'], ['CFBundleDisplayName', name], - ['CFBundleExecutable', 'maka-desktop-shell'], - ['CFBundleIconFile', 'Maka'], + ['CFBundleExecutable', executable], + ...(icon ? [['CFBundleIconFile', icon]] : []), ['CFBundleIdentifier', id], ['CFBundleInfoDictionaryVersion', '6.0'], ['CFBundleName', name], @@ -207,6 +258,11 @@ function infoPlist({ id, name, version }) { ].map(([key, value]) => ` ${key}\n ${escape(value)}`), ' NSHighResolutionCapable', ' ', + ...extra.map(([key, value]) => + value === true + ? ` ${key}\n ` + : ` ${key}\n ${escape(value)}`, + ), '', '', '', diff --git a/scripts/rust/build-cli.test.mjs b/scripts/rust/build-cli.test.mjs index 6097138fc9f..585dff744d0 100644 --- a/scripts/rust/build-cli.test.mjs +++ b/scripts/rust/build-cli.test.mjs @@ -124,6 +124,7 @@ test('CLI builds use the requested target and artifact path', async (t) => { const macos = triple.includes('-apple-darwin'); await mkdir(binaryDirectory, { recursive: true }); await writeFile(join(binaryDirectory, 'maka-desktop-shell'), 'shell bytes'); + await writeFile(join(binaryDirectory, 'maka-host-shell'), 'host shell bytes'); executable = join(binaryDirectory, triple.includes('-windows-') ? 'maka.exe' : 'maka'); const inputManifest = join(binaryDirectory, 'build/cli-source/out/product/bundle-inputs.json'); await mkdir(dirname(inputManifest), { recursive: true }); @@ -171,6 +172,24 @@ test('CLI builds use the requested target and artifact path', async (t) => { assert.equal(icon.readUInt32BE(12), icon.length - 8); assert.deepEqual(icon.subarray(16), logo()); assert.deepEqual(await readdir(join(contents, 'Resources')), ['Maka.icns']); + const host = join(contents, 'Helpers/Maka Host.app/Contents'); + assert.equal(await readFile(join(host, 'MacOS/maka-host-shell'), 'utf8'), 'host shell bytes'); + if (process.platform !== 'win32') + assert.equal((await lstat(join(host, 'MacOS/maka-host-shell'))).mode & 0o777, 0o755); + const hostPlist = await readFile(join(host, 'Info.plist'), 'utf8'); + // The helper keeps the same Info.plist across releases. + for (const [key, value] of [ + ['CFBundleExecutable', 'maka-host-shell'], + ['CFBundleIdentifier', 'org.apache.maka.host'], + ['CFBundleName', 'Maka Host'], + ['CFBundleShortVersionString', '1'], + ['CFBundleVersion', '1'], + ]) + assert.ok(hostPlist.includes(`${key}\n ${value}`), key); + assert.ok(hostPlist.includes('LSUIElement\n ')); + assert.ok(!hostPlist.includes('1.2.3')); + if (process.platform === 'darwin') + childProcess.execFileSync('plutil', ['-lint', join(host, 'Info.plist')]); } assert.equal(invocation.command, 'cargo'); assert.equal(invocation.options.cwd, root); @@ -181,6 +200,7 @@ test('CLI builds use the requested target and artifact path', async (t) => { 'maka-cli', '-p', 'maka-desktop-shell', + ...(macos ? ['-p', 'maka-host-shell'] : []), '-p', 'maka-python-runtime', '--release', @@ -201,6 +221,7 @@ test('CLI builds use the requested target and artifact path', async (t) => { const development = join(root, process.env.CARGO_TARGET_DIR, 'debug'); await mkdir(development, { recursive: true }); await writeFile(join(development, 'maka-desktop-shell'), 'shell bytes'); + await writeFile(join(development, 'maka-host-shell'), 'host shell bytes'); executable = join(development, process.platform === 'win32' ? 'maka.exe' : 'maka'); assert.equal( ( @@ -219,6 +240,7 @@ test('CLI builds use the requested target and artifact path', async (t) => { 'maka-cli', '-p', 'maka-desktop-shell', + ...(rustTarget().includes('-apple-darwin') ? ['-p', 'maka-host-shell'] : []), '-p', 'maka-python-runtime', '--timings', @@ -230,6 +252,10 @@ test('CLI builds use the requested target and artifact path', async (t) => { join(sourceRoot, 'target/aarch64-apple-darwin/release/maka-desktop-shell'), 'shell bytes', ); + await writeFile( + join(sourceRoot, 'target/aarch64-apple-darwin/release/maka-host-shell'), + 'host shell bytes', + ); executable = join(sourceRoot, 'target/aarch64-apple-darwin/release/maka'); assert.equal( ( @@ -254,6 +280,7 @@ test('a failed bundle staging keeps the previous Maka.app', async () => { await repository(root); const shell = join(root, 'maka-desktop-shell'); await writeFile(shell, 'original shell'); + await writeFile(join(root, 'maka-host-shell'), 'host shell'); const destination = join(root, 'bin/Maka.app'); await mkdir(dirname(destination)); await stageDesktopBundle(root, shell, destination); diff --git a/scripts/rust/pack-cli.test.mjs b/scripts/rust/pack-cli.test.mjs index af938b7d1e9..c9d3a623e8a 100644 --- a/scripts/rust/pack-cli.test.mjs +++ b/scripts/rust/pack-cli.test.mjs @@ -49,6 +49,7 @@ test('native archives carry the CLI and desktop shell without a terminal sidecar 'maka.exe', 'maka-desktop-shell', 'maka-desktop-shell.exe', + 'maka-host-shell', 'maka-service.exe', 'maka-python-worker', 'maka-python-worker.exe', @@ -85,6 +86,7 @@ test('native archives carry the CLI and desktop shell without a terminal sidecar assert.match(plist, /CFBundleIdentifier<\/key>\n org.apache.maka.desktop<\/string>/); assert.match(plist, /CFBundleShortVersionString<\/key>\n 0.2.0<\/string>/); assert.equal(execFileSync('tar', ['-xOf', archive, bundle + 'Resources/Maka.icns']).subarray(0, 4).toString(), 'icns'); + assert.equal(execFileSync('tar', ['-xOf', archive, bundle + 'Helpers/Maka Host.app/Contents/MacOS/maka-host-shell'], {encoding:'utf8'}), 'maka-host-shell bytes'); } for (const name of ['DISCLAIMER', 'LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.txt']) { assert.equal(execFileSync('tar', ['-xOf', archive, 'package/' + name], {encoding:'utf8'}), 'reviewed ' + name); diff --git a/scripts/rust/run-cli.test.mjs b/scripts/rust/run-cli.test.mjs index bccf8e9b3f7..2945106f97c 100644 --- a/scripts/rust/run-cli.test.mjs +++ b/scripts/rust/run-cli.test.mjs @@ -130,6 +130,7 @@ test('desktop development stages the current macOS shell before starting Maka', await mkdir(app, { recursive: true }); await writeFile(join(app, 'maka-desktop-shell'), 'previous desktop'); await writeFile(join(output, 'maka-desktop-shell'), 'current desktop'); + await writeFile(join(output, 'maka-host-shell'), 'current host shell'); const executable = join(output, 'maka'); const launched = []; t.mock.method(childProcess, 'spawn', (command, args) => { From 0e63e6e8ebef02ed8100e4ece85aa7a5dbbaa61f Mon Sep 17 00:00:00 2001 From: Xun Lu Date: Tue, 3 Nov 2026 01:15:48 +0800 Subject: [PATCH 3/6] feat(computer-use): ask for missing macOS permissions in the conversation When native Computer Use needs Accessibility or Screen Recording and the Host lacks it, the Host asks macOS to show its request and then shows a System permission request card in the conversation. The card names the missing grants and opens the matching System Settings pane. The Host checks the grants every 1.5 s while the card is open and continues the tool call as soon as they are on, with no retry and no restart. Cancelling the card fails the call with the permission_required lines. With request_permissions off, the call fails at once as before. The new system_permission interaction can only be cancelled by the user; only its producer commits SystemPermissionGranted, and it cannot time out. ACP clients get a prompt whose only option is Cancel, and a prompt whose interaction settles elsewhere no longer holds the session in RequiresAction. Co-Authored-By: Claude Opus 5.5 --- packages/plugin-sdk/src/contracts.ts | 145 ++++++++++++++++ packages/plugin-sdk/src/generated/runtime.ts | 30 +++- platform/computer-use/src/lib.rs | 1 + platform/computer-use/src/macos/tcc.rs | 41 ++++- .../computer-use/src/system_permission.rs | 81 +++++++++ .../event-log/src/interactions/records.rs | 4 + platform/event-log/tests/integration/main.rs | 1 + .../system_permission_interactions.rs | 138 +++++++++++++++ plugins/agent/api/src/contract/interaction.rs | 1 + plugins/agent/facts/src/interaction.rs | 40 ++++- plugins/agent/facts/src/interaction/decode.rs | 16 ++ plugins/agent/facts/src/interaction/tests.rs | 161 ++++++++++++++++- .../agent/facts/src/interaction/validation.rs | 38 +++- .../agent/native/src/owner/interactions.rs | 8 +- .../native/src/owner/tests/interactions.rs | 117 +++++++++++++ .../tests/service_tools/approval_park.rs | 3 + plugins/agent/ports/src/execution/message.rs | 1 + .../workspace-computer/native/src/evaluate.rs | 8 + .../workspace-computer/native/src/lib.rs | 1 + .../native/src/system_permission.rs | 164 ++++++++++++++++++ products/maka/acp/src/foreground.rs | 29 +++- products/maka/acp/src/interaction.rs | 6 +- .../maka/acp/src/interaction/permission.rs | 114 ++++++++++++ .../workbench/app/InteractionComposer.tsx | 71 ++++++-- .../workbench/app/SystemPermissionRequest.tsx | 77 ++++++++ .../maka/workbench/app/locale-conversation.ts | 25 +++ 26 files changed, 1285 insertions(+), 36 deletions(-) create mode 100644 platform/computer-use/src/system_permission.rs create mode 100644 platform/event-log/tests/integration/system_permission_interactions.rs create mode 100644 plugins/workspace/workspace-computer/native/src/system_permission.rs create mode 100644 products/maka/workbench/app/SystemPermissionRequest.tsx diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts index 8de4ca65d27..4bde4279df9 100644 --- a/packages/plugin-sdk/src/contracts.ts +++ b/packages/plugin-sdk/src/contracts.ts @@ -9648,6 +9648,37 @@ export const contracts = { 'authorityRevision', ], }, + { + description: + 'Missing operating-system grants the waiting tool call needs. The producer\npolls the real grant state and commits `SystemPermissionGranted` itself;\nthe user can only cancel.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission', + }, + toolUseId: { + type: 'string', + }, + grants: { + description: 'Distinct missing grants.', + type: 'array', + items: { + $ref: '#/$defs/SystemPermissionGrant', + }, + minItems: 1, + }, + application: { + description: + 'The application name the operating system lists in its settings.', + type: 'string', + minLength: 1, + maxLength: 256, + }, + }, + additionalProperties: false, + required: ['kind', 'toolUseId', 'grants', 'application'], + }, ], }, PermissionBasis: { @@ -10212,6 +10243,23 @@ export const contracts = { type: 'string', enum: ['low', 'high'], }, + SystemPermissionGrant: { + description: + 'A macOS privacy grant (TCC) that the Host process itself needs before native\nComputer Use can run. This is operating-system state the user changes in\nSystem Settings, not a Maka grant; the Host observes it and commits the outcome.', + oneOf: [ + { + description: 'System Settings > Privacy & Security > Accessibility.', + type: 'string', + const: 'accessibility', + }, + { + description: + 'System Settings > Privacy & Security > Screen & System Audio Recording.', + type: 'string', + const: 'screen_recording', + }, + ], + }, InteractionOutcome: { oneOf: [ { @@ -10366,6 +10414,24 @@ export const contracts = { additionalProperties: false, required: ['kind', 'decision', 'committedAt'], }, + { + description: + 'Producer-committed once every requested system grant is present; no answer maps here.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission_granted', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'committedAt'], + }, { type: 'object', properties: { @@ -13500,6 +13566,7 @@ export const contracts = { 'form', 'client_capability', 'computer_app', + 'system_permission', ], }, }, @@ -15615,6 +15682,17 @@ export const contracts = { additionalProperties: false, required: ['kind'], }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission_cancel', + }, + }, + additionalProperties: false, + required: ['kind'], + }, ], }, Decision: { @@ -16038,6 +16116,37 @@ export const contracts = { 'authorityRevision', ], }, + { + description: + 'Missing operating-system grants the waiting tool call needs. The producer\npolls the real grant state and commits `SystemPermissionGranted` itself;\nthe user can only cancel.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission', + }, + toolUseId: { + type: 'string', + }, + grants: { + description: 'Distinct missing grants.', + type: 'array', + items: { + $ref: '#/$defs/SystemPermissionGrant', + }, + minItems: 1, + }, + application: { + description: + 'The application name the operating system lists in its settings.', + type: 'string', + minLength: 1, + maxLength: 256, + }, + }, + additionalProperties: false, + required: ['kind', 'toolUseId', 'grants', 'application'], + }, ], }, PermissionBasis: { @@ -16602,6 +16711,23 @@ export const contracts = { type: 'string', enum: ['low', 'high'], }, + SystemPermissionGrant: { + description: + 'A macOS privacy grant (TCC) that the Host process itself needs before native\nComputer Use can run. This is operating-system state the user changes in\nSystem Settings, not a Maka grant; the Host observes it and commits the outcome.', + oneOf: [ + { + description: 'System Settings > Privacy & Security > Accessibility.', + type: 'string', + const: 'accessibility', + }, + { + description: + 'System Settings > Privacy & Security > Screen & System Audio Recording.', + type: 'string', + const: 'screen_recording', + }, + ], + }, InteractionOutcome: { oneOf: [ { @@ -16756,6 +16882,24 @@ export const contracts = { additionalProperties: false, required: ['kind', 'decision', 'committedAt'], }, + { + description: + 'Producer-committed once every requested system grant is present; no answer maps here.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission_granted', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'committedAt'], + }, { type: 'object', properties: { @@ -22555,6 +22699,7 @@ export const contracts = { 'form', 'client_capability', 'computer_app', + 'system_permission', ], }, AgentComputerApps: { diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts index 81eccdc400f..e8122f85b19 100644 --- a/packages/plugin-sdk/src/generated/runtime.ts +++ b/packages/plugin-sdk/src/generated/runtime.ts @@ -3271,7 +3271,8 @@ export type AgentInteractionKind = | 'permissions' | 'form' | 'client_capability' - | 'computer_app'; + | 'computer_app' + | 'system_permission'; export type InteractionAnswer = | { kind: 'service_approval'; @@ -3315,6 +3316,9 @@ export type InteractionAnswer = } | { kind: 'computer_app_cancel'; + } + | { + kind: 'system_permission_cancel'; }; export type Decision = 'allow' | 'deny'; export type Decision2 = @@ -3385,6 +3389,20 @@ export type InteractionRequest = display: ComputerAppDisplay; allowAlways: boolean; authorityRevision: number; + } + | { + kind: 'system_permission'; + toolUseId: string; + /** + * Distinct missing grants. + * + * @minItems 1 + */ + grants: [SystemPermissionGrant, ...SystemPermissionGrant[]]; + /** + * The application name the operating system lists in its settings. + */ + application: string; }; export type FormField = { name: string; @@ -3466,6 +3484,12 @@ export type GrantScope = }; export type ComputerAppPlatform = 'macos' | 'windows'; export type ComputerAppRisk = 'low' | 'high'; +/** + * A macOS privacy grant (TCC) that the Host process itself needs before native + * Computer Use can run. This is operating-system state the user changes in + * System Settings, not a Maka grant; the Host observes it and commits the outcome. + */ +export type SystemPermissionGrant = 'accessibility' | 'screen_recording'; export type InteractionOutcome = | { kind: 'service_approval_decision'; @@ -3513,6 +3537,10 @@ export type InteractionOutcome = decision: ComputerAppDecision; committedAt: number; } + | { + kind: 'system_permission_granted'; + committedAt: number; + } | { kind: 'closure'; reason: ClosureReason; diff --git a/platform/computer-use/src/lib.rs b/platform/computer-use/src/lib.rs index 43f80d338fe..2af2ebd20a0 100644 --- a/platform/computer-use/src/lib.rs +++ b/platform/computer-use/src/lib.rs @@ -63,6 +63,7 @@ mod permission_subject; pub mod protocol; #[cfg(feature = "native-desktop")] mod session; +pub mod system_permission; #[cfg(all(feature = "native-desktop", target_os = "windows"))] mod windows_permission_subject; diff --git a/platform/computer-use/src/macos/tcc.rs b/platform/computer-use/src/macos/tcc.rs index 81522552a4f..8d602d305f2 100644 --- a/platform/computer-use/src/macos/tcc.rs +++ b/platform/computer-use/src/macos/tcc.rs @@ -205,18 +205,44 @@ fn path_of(pid: libc::pid_t) -> Option { String::from_utf8(buffer).ok() } -pub(crate) fn require(grant: Grant) -> Result<(), ToolError> { +impl From for Grant { + fn from(grant: maka_agent_facts::interaction::SystemPermissionGrant) -> Self { + use maka_agent_facts::interaction::SystemPermissionGrant; + match grant { + SystemPermissionGrant::Accessibility => Self::Accessibility, + SystemPermissionGrant::ScreenRecording => Self::ScreenRecording, + } + } +} + +/// Whether the grant is in effect, confirming it with a real call when it +/// has not been confirmed yet. It blocks for the call. +pub(crate) fn present(grant: Grant) -> bool { if grant.confirmed().load(Ordering::Relaxed) { - return Ok(()); + return true; } - if grant.observed() { + let observed = grant.observed(); + if observed { grant.confirmed().store(true, Ordering::Relaxed); - return Ok(()); } + observed +} + +/// Asks macOS to show its request for the grant, once per Host. Returns +/// whether this call asked. +pub(crate) fn ask(grant: Grant) -> bool { let asked = !grant.asked().swap(true, Ordering::Relaxed); if asked { grant.request(); } + asked +} + +pub(crate) fn require(grant: Grant) -> Result<(), ToolError> { + if present(grant) { + return Ok(()); + } + let asked = ask(grant); Err(ToolError::Failed(message( grant, &responsible_application(), @@ -253,6 +279,11 @@ fn suspect(error: &ToolError) -> Option { } } +/// The failure for a grant still missing after the user was asked. +pub(crate) fn still_missing(grant: Grant, application: &str) -> String { + message(grant, application, false) +} + /// The tool error for a missing grant. `permission_required:` is the stable /// prefix; the rest tells the user which switch to turn on. fn message(grant: Grant, application: &str, asked: bool) -> String { @@ -271,7 +302,7 @@ fn message(grant: Grant, application: &str, asked: bool) -> String { } /// The name macOS shows for the process that holds this Host's grants. -fn responsible_application() -> String { +pub(crate) fn responsible_application() -> String { responsible_path() .map(|path| application_name(&path)) .unwrap_or_else(|| "the application that started Maka".into()) diff --git a/platform/computer-use/src/system_permission.rs b/platform/computer-use/src/system_permission.rs new file mode 100644 index 00000000000..d2fbb0eace8 --- /dev/null +++ b/platform/computer-use/src/system_permission.rs @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//! The operating system's privacy grants native Computer Use needs. +//! +//! On macOS these are Accessibility and Screen Recording, which belong to +//! the application responsible for the Host. Other platforms need none. + +use maka_agent_facts::interaction::SystemPermissionGrant; + +/// The grants native desktop work needs that are not in effect now, checked +/// with real calls. It blocks for those calls. +pub fn missing() -> Vec { + #[cfg(all(feature = "native-desktop", target_os = "macos"))] + { + [ + SystemPermissionGrant::Accessibility, + SystemPermissionGrant::ScreenRecording, + ] + .into_iter() + .filter(|grant| !crate::macos::tcc::present((*grant).into())) + .collect() + } + #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] + Vec::new() +} + +/// Asks the operating system to show its own request for each grant, at +/// most once per grant for this Host. +pub fn request(grants: &[SystemPermissionGrant]) { + #[cfg(all(feature = "native-desktop", target_os = "macos"))] + for grant in grants { + crate::macos::tcc::ask((*grant).into()); + } + #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] + let _ = grants; +} + +/// The name System Settings lists for the application the grants belong to. +pub fn application() -> String { + #[cfg(all(feature = "native-desktop", target_os = "macos"))] + { + crate::macos::tcc::responsible_application() + } + #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] + String::new() +} + +/// The tool failure for grants still missing: one `permission_required:` +/// line for each, naming the switch to turn on. +pub fn failure(grants: &[SystemPermissionGrant], application: &str) -> String { + #[cfg(all(feature = "native-desktop", target_os = "macos"))] + { + grants + .iter() + .map(|grant| crate::macos::tcc::still_missing((*grant).into(), application)) + .collect::>() + .join("\n") + } + #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] + { + let _ = (grants, application); + String::new() + } +} diff --git a/platform/event-log/src/interactions/records.rs b/platform/event-log/src/interactions/records.rs index 9a7e0ecfdc6..c8252219c71 100644 --- a/platform/event-log/src/interactions/records.rs +++ b/platform/event-log/src/interactions/records.rs @@ -86,6 +86,10 @@ pub(crate) fn equivalent(left: &InteractionOutcome, right: &InteractionOutcome) decision: right, .. }, ) => left == right, + ( + InteractionOutcome::SystemPermissionGranted { .. }, + InteractionOutcome::SystemPermissionGranted { .. }, + ) => true, ( InteractionOutcome::Closure { reason: left, .. }, InteractionOutcome::Closure { reason: right, .. }, diff --git a/platform/event-log/tests/integration/main.rs b/platform/event-log/tests/integration/main.rs index 0377ec46a04..e3528fbfc70 100644 --- a/platform/event-log/tests/integration/main.rs +++ b/platform/event-log/tests/integration/main.rs @@ -64,6 +64,7 @@ mod steering; mod stream_delivery; mod streaming_usage; mod support; +mod system_permission_interactions; mod tool_ancestry; mod tool_payloads; mod tool_rejection; diff --git a/platform/event-log/tests/integration/system_permission_interactions.rs b/platform/event-log/tests/integration/system_permission_interactions.rs new file mode 100644 index 00000000000..6208b047232 --- /dev/null +++ b/platform/event-log/tests/integration/system_permission_interactions.rs @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use maka_agent_facts::interaction::ClosureReason; +use maka_agent_facts::interaction::InteractionAnswer; +use maka_agent_facts::interaction::InteractionOutcome; +use maka_agent_facts::interaction::InteractionRecord; +use maka_event_log::EventLog; +use serde_json::json; + +fn record(request_id: &str) -> InteractionRecord { + serde_json::from_value(json!({ + "sessionId":"session","turnId":"turn","runId":"run","requestId":request_id,"createdAt":2, + "request":{"kind":"system_permission","toolUseId":"tool", + "grants":["accessibility","screen_recording"],"application":"Maka Host"}, + "outcome":null + })) + .unwrap() +} + +#[tokio::test] +async fn system_permission_grant_is_producer_committed_once_and_survives_reopen() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("runtime.sqlite"); + let log = EventLog::open(&path).await.unwrap(); + log.create_session("session", "create", &json!({}), 1) + .await + .unwrap(); + assert!( + log.establish_interaction(&record("granted")) + .await + .unwrap() + .matches + ); + assert!( + log.commit_interaction_outcome( + "granted", + InteractionOutcome::QuestionAnswer { + answers: vec![Some("yes".into())], + committed_at: 3, + } + ) + .await + .is_err(), + "only the system permission outcome or a closure can settle the request" + ); + let committed = log + .commit_interaction_outcome( + "granted", + InteractionOutcome::SystemPermissionGranted { committed_at: 3 }, + ) + .await + .unwrap(); + assert!(committed.matches); + assert!( + log.commit_interaction_outcome( + "granted", + InteractionOutcome::SystemPermissionGranted { committed_at: 4 }, + ) + .await + .unwrap() + .matches, + "a repeated grant observation is idempotent" + ); + assert!( + !log.commit_interaction_outcome( + "granted", + InteractionAnswer::SystemPermissionCancel {}.into_outcome(5), + ) + .await + .unwrap() + .matches, + "first outcome wins over a late cancel" + ); + + assert!( + log.establish_interaction(&record("cancelled")) + .await + .unwrap() + .matches + ); + let cancel = InteractionAnswer::SystemPermissionCancel {}; + let cancelled = log + .commit_interaction_outcome("cancelled", cancel.clone().into_outcome(6)) + .await + .unwrap(); + assert!(cancelled.matches); + assert!(cancel.matches_outcome(cancelled.record.outcome.as_ref().unwrap())); + assert!( + !log.commit_interaction_outcome( + "cancelled", + InteractionOutcome::SystemPermissionGranted { committed_at: 7 }, + ) + .await + .unwrap() + .matches + ); + assert!( + log.commit_interaction_outcome( + "cancelled", + InteractionOutcome::Closure { + reason: ClosureReason::TimedOut, + committed_at: 8, + } + ) + .await + .is_err(), + "the card has no response deadline" + ); + + log.close().await.unwrap(); + let reopened = EventLog::open(&path).await.unwrap(); + assert_eq!( + reopened.interaction("granted").await.unwrap().unwrap(), + committed.record + ); + assert_eq!( + reopened.interaction("cancelled").await.unwrap().unwrap(), + cancelled.record + ); + reopened.close().await.unwrap(); +} diff --git a/plugins/agent/api/src/contract/interaction.rs b/plugins/agent/api/src/contract/interaction.rs index fc803d2f21f..e490adf4428 100644 --- a/plugins/agent/api/src/contract/interaction.rs +++ b/plugins/agent/api/src/contract/interaction.rs @@ -48,6 +48,7 @@ pub enum InteractionKind { Form, ClientCapability, ComputerApp, + SystemPermission, } #[derive(Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/plugins/agent/facts/src/interaction.rs b/plugins/agent/facts/src/interaction.rs index 367c4fcb551..125f05bc5b5 100644 --- a/plugins/agent/facts/src/interaction.rs +++ b/plugins/agent/facts/src/interaction.rs @@ -138,6 +138,18 @@ pub enum Decision { Deny, } +/// A macOS privacy grant (TCC) that the Host process itself needs before native +/// Computer Use can run. This is operating-system state the user changes in +/// System Settings, not a Maka grant; the Host observes it and commits the outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SystemPermissionGrant { + /// System Settings > Privacy & Security > Accessibility. + Accessibility, + /// System Settings > Privacy & Security > Screen & System Audio Recording. + ScreenRecording, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ClosureReason { @@ -199,6 +211,18 @@ pub enum InteractionRequest { allow_always: bool, authority_revision: u64, }, + /// Missing operating-system grants the waiting tool call needs. The producer + /// polls the real grant state and commits `SystemPermissionGranted` itself; + /// the user can only cancel. + SystemPermission { + tool_use_id: String, + /// Distinct missing grants. + #[schemars(length(min = 1))] + grants: Vec, + /// The application name the operating system lists in its settings. + #[schemars(length(min = 1, max = 256))] + application: String, + }, } #[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)] @@ -224,6 +248,7 @@ pub enum InteractionAnswer { decision: ComputerAppDecision, }, ComputerAppCancel {}, + SystemPermissionCancel {}, } #[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)] @@ -259,6 +284,8 @@ pub enum InteractionOutcome { decision: ComputerAppDecision, committed_at: u64, }, + /// Producer-committed once every requested system grant is present; no answer maps here. + SystemPermissionGranted { committed_at: u64 }, Closure { reason: ClosureReason, committed_at: u64, @@ -274,6 +301,7 @@ impl InteractionOutcome { | Self::FormAnswer { committed_at, .. } | Self::ClientCapabilityDecision { committed_at, .. } | Self::ComputerAppDecision { committed_at, .. } + | Self::SystemPermissionGranted { committed_at } | Self::Closure { committed_at, .. } => *committed_at, } } @@ -302,10 +330,12 @@ impl InteractionAnswer { decision, committed_at, }, - Self::ComputerAppCancel {} => InteractionOutcome::Closure { - reason: ClosureReason::ProducerCancelled, - committed_at, - }, + Self::ComputerAppCancel {} | Self::SystemPermissionCancel {} => { + InteractionOutcome::Closure { + reason: ClosureReason::ProducerCancelled, + committed_at, + } + } Self::Form { result } => InteractionOutcome::FormAnswer { result, committed_at, @@ -344,7 +374,7 @@ impl InteractionAnswer { }, ) => left == right, ( - Self::ComputerAppCancel {}, + Self::ComputerAppCancel {} | Self::SystemPermissionCancel {}, InteractionOutcome::Closure { reason: ClosureReason::ProducerCancelled, .. diff --git a/plugins/agent/facts/src/interaction/decode.rs b/plugins/agent/facts/src/interaction/decode.rs index 689d0f8e848..47a6c848c13 100644 --- a/plugins/agent/facts/src/interaction/decode.rs +++ b/plugins/agent/facts/src/interaction/decode.rs @@ -105,6 +105,14 @@ fn request(v: &Value) -> Result { authority_revision: field(v, "authorityRevision")?, } } + Some("system_permission") => { + shape(v, &["kind", "toolUseId", "grants", "application"])?; + InteractionRequest::SystemPermission { + tool_use_id: field(v, "toolUseId")?, + grants: field(v, "grants")?, + application: field(v, "application")?, + } + } Some("form") => { shape(v, &["kind", "toolUseId", "message", "requester", "fields"])?; let input = crate::capability::form_decode::decode_input(&serde_json::json!({ @@ -173,6 +181,10 @@ fn answer(v: &Value) -> Result { shape(v, &["kind"])?; InteractionAnswer::ComputerAppCancel {} } + Some("system_permission_cancel") => { + shape(v, &["kind"])?; + InteractionAnswer::SystemPermissionCancel {} + } Some("form") => InteractionAnswer::Form { result: result(v, false)?, }, @@ -219,6 +231,10 @@ fn outcome(v: &Value) -> Result { committed_at, } } + Some("system_permission_granted") => { + shape(v, &["kind", "committedAt"])?; + InteractionOutcome::SystemPermissionGranted { committed_at } + } Some("closure") => { shape(v, &["kind", "reason", "committedAt"])?; InteractionOutcome::Closure { diff --git a/plugins/agent/facts/src/interaction/tests.rs b/plugins/agent/facts/src/interaction/tests.rs index 13a52a2d632..98b37da2747 100644 --- a/plugins/agent/facts/src/interaction/tests.rs +++ b/plugins/agent/facts/src/interaction/tests.rs @@ -128,5 +128,162 @@ fn browser_download_consent_is_origin_scoped_and_distinct_from_page_access() { } } -// Append inside plugins/agent/facts/src/interaction/tests.rs. Uses the real -// canonical request decoder, decisions and serializer on every build host. +fn system_permission_request() -> Value { + json!({ + "kind": "system_permission", + "toolUseId": "tool_123", + "grants": ["accessibility", "screen_recording"], + "application": "Maka Host" + }) +} + +#[test] +fn system_permission_request_round_trips_and_validates_grants_and_application() { + let request: InteractionRequest = serde_json::from_value(system_permission_request()).unwrap(); + assert_eq!( + request, + InteractionRequest::SystemPermission { + tool_use_id: "tool_123".into(), + grants: vec![ + SystemPermissionGrant::Accessibility, + SystemPermissionGrant::ScreenRecording + ], + application: "Maka Host".into(), + } + ); + assert_eq!( + serde_json::to_value(&request).unwrap(), + system_permission_request() + ); + let mut single = system_permission_request(); + single["grants"] = json!(["screen_recording"]); + assert!(serde_json::from_value::(single).is_ok()); + + for grants in [ + json!([]), + json!(["accessibility", "accessibility"]), + json!(["microphone"]), + json!(null), + ] { + let mut input = system_permission_request(); + input["grants"] = grants; + assert!(serde_json::from_value::(input).is_err()); + } + for application in [ + json!(""), + json!(" "), + json!(" Maka Host"), + json!("Maka\nHost"), + json!("Maka\0Host"), + json!("a".repeat(257)), + json!(null), + ] { + let mut input = system_permission_request(); + input["application"] = application; + assert!(serde_json::from_value::(input).is_err()); + } + let mut input = system_permission_request(); + input["toolUseId"] = json!(""); + assert!(serde_json::from_value::(input).is_err()); + let mut input = system_permission_request(); + input["extra"] = json!(true); + assert!(serde_json::from_value::(input).is_err()); + let mut input = system_permission_request(); + input.as_object_mut().unwrap().remove("application"); + assert!(serde_json::from_value::(input).is_err()); +} + +#[test] +fn system_permission_cancel_closes_as_producer_cancelled_only_for_its_request() { + let answer: InteractionAnswer = + serde_json::from_value(json!({"kind": "system_permission_cancel"})).unwrap(); + assert_eq!(answer, InteractionAnswer::SystemPermissionCancel {}); + assert_eq!( + serde_json::to_value(&answer).unwrap(), + json!({"kind": "system_permission_cancel"}) + ); + assert!( + serde_json::from_value::( + json!({"kind": "system_permission_cancel", "extra": 1}) + ) + .is_err() + ); + let outcome = answer.clone().into_outcome(7); + assert_eq!( + outcome, + InteractionOutcome::Closure { + reason: ClosureReason::ProducerCancelled, + committed_at: 7, + } + ); + assert!(answer.matches_outcome(&outcome)); + assert!( + !answer.matches_outcome(&InteractionOutcome::SystemPermissionGranted { committed_at: 7 }) + ); + + let request: InteractionRequest = serde_json::from_value(system_permission_request()).unwrap(); + let computer_app: InteractionRequest = serde_json::from_value(computer_app_request()).unwrap(); + assert_eq!(answer.validate_for_request(&request), Ok(())); + assert!(answer.validate_for_request(&computer_app).is_err()); + assert!( + InteractionAnswer::ComputerAppCancel {} + .validate_for_request(&request) + .is_err() + ); +} + +#[test] +fn system_permission_granted_is_producer_committed_and_only_for_its_request() { + let outcome: InteractionOutcome = + serde_json::from_value(json!({"kind": "system_permission_granted", "committedAt": 42})) + .unwrap(); + assert_eq!( + outcome, + InteractionOutcome::SystemPermissionGranted { committed_at: 42 } + ); + assert_eq!(outcome.committed_at(), 42); + assert_eq!( + serde_json::to_value(&outcome).unwrap(), + json!({"kind": "system_permission_granted", "committedAt": 42}) + ); + for invalid in [ + json!({"kind": "system_permission_granted"}), + json!({"kind": "system_permission_granted", "committedAt": 42, "grants": []}), + json!({"kind": "system_permission_granted", "committedAt": MAX_SAFE_INTEGER + 1}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } + assert!( + serde_json::from_value::(json!({"kind": "system_permission_granted"})) + .is_err(), + "no user answer may claim the grant" + ); + + let request: InteractionRequest = serde_json::from_value(system_permission_request()).unwrap(); + let computer_app: InteractionRequest = serde_json::from_value(computer_app_request()).unwrap(); + assert_eq!(outcome.validate_for_request(&request), Ok(())); + assert!(outcome.validate_for_request(&computer_app).is_err()); + let question: InteractionRequest = serde_json::from_value(json!({ + "kind": "question", + "toolUseId": "tool_123", + "questions": [{"question": "Continue?", "options": [{"label": "Yes"}, {"label": "No"}]}] + })) + .unwrap(); + assert!(outcome.validate_for_request(&question).is_err()); + assert_eq!( + InteractionOutcome::Closure { + reason: ClosureReason::ProducerCancelled, + committed_at: 1, + } + .validate_for_request(&request), + Ok(()) + ); + assert!( + InteractionOutcome::ComputerAppDecision { + decision: ComputerAppDecision::Once, + committed_at: 1, + } + .validate_for_request(&request) + .is_err() + ); +} diff --git a/plugins/agent/facts/src/interaction/validation.rs b/plugins/agent/facts/src/interaction/validation.rs index 0511c202409..6fa7499084a 100644 --- a/plugins/agent/facts/src/interaction/validation.rs +++ b/plugins/agent/facts/src/interaction/validation.rs @@ -21,6 +21,9 @@ use super::*; use crate::capability::FormInput; pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +/// Upper bound for the operating-system application name shown on a system permission card; +/// the wire schema declares the same bound. +const MAX_SYSTEM_PERMISSION_APPLICATION_CHARS: usize = 256; pub fn entity_id(value: &str) -> Result<(), &'static str> { if value.is_empty() @@ -249,6 +252,32 @@ impl InteractionRequest { return Err("Invalid computer application authority revision"); } } + Self::SystemPermission { + tool_use_id, + grants, + application, + } => { + if tool_use_id.is_empty() { + return Err("Invalid interaction tool use identity"); + } + if grants.is_empty() { + return Err("System permission request requires a grant"); + } + if grants + .iter() + .enumerate() + .any(|(index, grant)| grants[..index].contains(grant)) + { + return Err("Duplicate system permission grant"); + } + if application.trim().is_empty() + || application.trim() != application + || application.chars().count() > MAX_SYSTEM_PERMISSION_APPLICATION_CHARS + || application.chars().any(char::is_control) + { + return Err("Invalid system permission application name"); + } + } } Ok(()) } @@ -286,7 +315,8 @@ impl InteractionOutcome { | InteractionRequest::ServiceApproval { .. } | InteractionRequest::Question { .. } | InteractionRequest::Permissions { .. } - | InteractionRequest::ComputerApp { .. }, + | InteractionRequest::ComputerApp { .. } + | InteractionRequest::SystemPermission { .. }, Self::Closure { reason: ClosureReason::TimedOut, .. @@ -317,6 +347,7 @@ impl InteractionOutcome { ) => question::validate_answer_count(answers, questions), (_, Self::Closure { .. }) | (InteractionRequest::ServiceApproval { .. }, Self::ServiceApprovalDecision { .. }) + | (InteractionRequest::SystemPermission { .. }, Self::SystemPermissionGranted { .. }) | ( InteractionRequest::ClientCapability { .. }, Self::ClientCapabilityDecision { .. }, @@ -387,7 +418,10 @@ impl InteractionAnswer { Self::ComputerApp { decision }, InteractionRequest::ComputerApp { allow_always, .. }, ) => decision.validate_for(*allow_always), - (Self::ComputerAppCancel {}, InteractionRequest::ComputerApp { .. }) => Ok(()), + (Self::ComputerAppCancel {}, InteractionRequest::ComputerApp { .. }) + | (Self::SystemPermissionCancel {}, InteractionRequest::SystemPermission { .. }) => { + Ok(()) + } _ => Err("Interaction answer does not match request"), } } diff --git a/plugins/agent/native/src/owner/interactions.rs b/plugins/agent/native/src/owner/interactions.rs index b7543b5b3ad..803b5c4cdff 100644 --- a/plugins/agent/native/src/owner/interactions.rs +++ b/plugins/agent/native/src/owner/interactions.rs @@ -51,7 +51,8 @@ impl Owner { | InteractionRequest::Question { tool_use_id, .. } | InteractionRequest::Form { tool_use_id, .. } | InteractionRequest::ClientCapability { tool_use_id, .. } - | InteractionRequest::ComputerApp { tool_use_id, .. } => { + | InteractionRequest::ComputerApp { tool_use_id, .. } + | InteractionRequest::SystemPermission { tool_use_id, .. } => { Some(tool_use_id.clone()) } }, @@ -66,6 +67,9 @@ impl Owner { InteractionKind::ClientCapability } InteractionRequest::ComputerApp { .. } => InteractionKind::ComputerApp, + InteractionRequest::SystemPermission { .. } => { + InteractionKind::SystemPermission + } }, }) .collect(), @@ -141,6 +145,7 @@ impl Owner { | InteractionRequest::ServiceApproval { .. } | InteractionRequest::Permissions { .. } | InteractionRequest::Form { .. } + | InteractionRequest::SystemPermission { .. } ) { return reject(InteractionRejection::Unsupported); @@ -154,6 +159,7 @@ impl Owner { | InteractionAnswer::ClientCapability { .. } | InteractionAnswer::ComputerApp { .. } | InteractionAnswer::ComputerAppCancel {} + | InteractionAnswer::SystemPermissionCancel {} ) || input.answer.validate_for_request(&record.request).is_err() { return reject(InteractionRejection::InvalidAnswer); diff --git a/plugins/agent/native/src/owner/tests/interactions.rs b/plugins/agent/native/src/owner/tests/interactions.rs index 9ef5fa28dba..8c23a317c3c 100644 --- a/plugins/agent/native/src/owner/tests/interactions.rs +++ b/plugins/agent/native/src/owner/tests/interactions.rs @@ -192,6 +192,123 @@ async fn native_browser_consent_accepts_original_producer_and_rejects_foreign_pr .expect("native browser producer consent fixture timed out"); } +#[tokio::test] +async fn native_system_permission_is_listed_cancellable_and_producer_granted() { + tokio::time::timeout(Duration::from_secs(30), async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let fixture = Fixture::new( + &format!("http://{}/v1", listener.local_addr().unwrap()), + None, + ) + .await; + let session = fixture.create("System permission").await; + let started = fixture.send(&session).await.unwrap(); + let (mut held, _) = listener.accept().await.unwrap(); + request(&mut held).await; + let store = fixture.session(&session).await; + let invocation = store + .session_projection::(&session) + .await + .unwrap() + .unwrap() + .root_turn + .unwrap() + .invocation; + let observed: Result<(), String> = async { + for id in ["system_cancelled", "system_granted"] { + let record = InteractionRecord { + session_id: session.clone(), + turn_id: invocation.turn_id.clone(), + run_id: invocation.run_id.clone(), + request_id: id.into(), + created_at: 1, + outcome: None, + request: maka_agent_facts::interaction::InteractionRequest::SystemPermission { + tool_use_id: "computer_tool".into(), + grants: vec![ + maka_agent_facts::interaction::SystemPermissionGrant::Accessibility, + maka_agent_facts::interaction::SystemPermissionGrant::ScreenRecording, + ], + application: "Maka Host".into(), + }, + }; + store + .establish_run_interaction(&invocation, &record) + .await + .map_err(|error| error.to_string())?; + } + let listed = fixture + .query("pendingInteractions", json!({"sessionId":session})) + .await?; + for id in ["system_cancelled", "system_granted"] { + let entries = listed["entries"].as_array().ok_or("pending entries")?; + if !entries.contains( + &json!({"requestId":id,"kind":"system_permission","toolUseId":"computer_tool"}), + ) { + return Err(format!("system permission was not listed: {listed}")); + } + } + let respond = |answer: Value| { + fixture.mutation( + 2, + "respond", + json!({"sessionId":session,"requestId":"system_cancelled","answer":answer}), + Uuid::new_v4(), + ) + }; + let foreign = respond(json!({"kind":"computer_app_cancel"})).await?; + if foreign != json!({"kind":"rejected","reason":"invalid_answer"}) { + return Err(format!("foreign cancel was accepted: {foreign}")); + } + if respond(json!({"kind":"system_permission_granted"})) + .await + .is_ok() + { + return Err("a user answer claimed the system grant".into()); + } + let cancelled = respond(json!({"kind":"system_permission_cancel"})).await?; + if cancelled["kind"] != "recorded" + || cancelled["record"]["outcome"]["kind"] != "closure" + || cancelled["record"]["outcome"]["reason"] != "producer_cancelled" + { + return Err(format!( + "system permission cancel was not recorded: {cancelled}" + )); + } + let granted = store + .commit_interaction_outcome( + "system_granted", + InteractionOutcome::SystemPermissionGranted { committed_at: 2 }, + ) + .await + .map_err(|error| error.to_string())?; + if !granted.matches + || granted.record.outcome + != Some(InteractionOutcome::SystemPermissionGranted { committed_at: 2 }) + { + return Err("producer grant was not committed".into()); + } + Ok(()) + } + .await; + fixture + .mutation( + 2, + "stop", + json!({"sessionId":session,"invocationId":started["invocationId"]}), + Uuid::new_v4(), + ) + .await + .unwrap(); + fixture.drained().await; + fixture.app.shutdown().await.unwrap(); + fixture.config.shutdown().await.unwrap(); + observed.unwrap(); + }) + .await + .expect("native system permission fixture timed out"); +} + #[tokio::test] async fn native_question_roundtrip_is_bound_owned_and_first_wins() { tokio::time::timeout(Duration::from_secs(30), async { diff --git a/plugins/agent/native/src/owner/tests/service_tools/approval_park.rs b/plugins/agent/native/src/owner/tests/service_tools/approval_park.rs index f435b039e55..65da76ef938 100644 --- a/plugins/agent/native/src/owner/tests/service_tools/approval_park.rs +++ b/plugins/agent/native/src/owner/tests/service_tools/approval_park.rs @@ -98,6 +98,9 @@ async fn diagnostic_state(fixture: &Fixture, session: &str) -> String { maka_agent_facts::interaction::InteractionRequest::ComputerApp { .. } => "computer_app", + maka_agent_facts::interaction::InteractionRequest::SystemPermission { + .. + } => "system_permission", }; (record.request_id, kind) }) diff --git a/plugins/agent/ports/src/execution/message.rs b/plugins/agent/ports/src/execution/message.rs index 9d5bccc3db1..f1404ba1853 100644 --- a/plugins/agent/ports/src/execution/message.rs +++ b/plugins/agent/ports/src/execution/message.rs @@ -115,6 +115,7 @@ pub enum InteractionKind { Permissions, ClientCapability, ComputerApp, + SystemPermission, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/plugins/workspace/workspace-computer/native/src/evaluate.rs b/plugins/workspace/workspace-computer/native/src/evaluate.rs index 63f4442b7b5..75eb0c381e4 100644 --- a/plugins/workspace/workspace-computer/native/src/evaluate.rs +++ b/plugins/workspace/workspace-computer/native/src/evaluate.rs @@ -372,6 +372,14 @@ impl Computer { } targets.push(api::audio_target()); } + // Native desktop work needs the operating system's privacy grants; + // resolving an app below already uses them. + if !browser_command(&command) + && !matches!(command, maka_computer_use::protocol::Command::Documentation) + { + system_permission::ensure(self, &call, basis, &operation_id, &cancellation, &cell) + .await?; + } let subject = { let mut desktop = tokio::select! { biased; () = cancellation.cancelled() => return Err(failed("Computer Use cancelled before app resolution").into()), diff --git a/plugins/workspace/workspace-computer/native/src/lib.rs b/plugins/workspace/workspace-computer/native/src/lib.rs index 3ebe30aaa20..c0ad1a00a57 100644 --- a/plugins/workspace/workspace-computer/native/src/lib.rs +++ b/plugins/workspace/workspace-computer/native/src/lib.rs @@ -27,6 +27,7 @@ mod browser_popup; mod evaluate; mod native_monitor; mod sessions; +mod system_permission; use futures_util::{FutureExt, future::BoxFuture}; use maka_agent_facts::{tool_call::ToolOrigin, tools::ToolError}; diff --git a/plugins/workspace/workspace-computer/native/src/system_permission.rs b/plugins/workspace/workspace-computer/native/src/system_permission.rs new file mode 100644 index 00000000000..a13407cdd25 --- /dev/null +++ b/plugins/workspace/workspace-computer/native/src/system_permission.rs @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//! The operating system's privacy grants native desktop work needs. When one +//! is missing, the Host asks the operating system to show its request and +//! shows a card naming the switches to turn on, then waits: it checks the +//! grants again while the card is open and continues the call once they are +//! in effect. Cancelling the card fails the call as before. +use super::*; +use maka_agent_facts::interaction::{ + InteractionOutcome, InteractionRecord, InteractionRequest, SystemPermissionGrant, +}; +use maka_agent_facts::tools::ToolCallContext; +use maka_agent_facts::workspace::PermissionBasis; +use maka_capabilities::call::Scope; +use maka_computer_use::system_permission; +use sha2::{Digest, Sha256}; + +/// How often the grants are checked again while the card is open. +const RECHECK: std::time::Duration = std::time::Duration::from_millis(1500); + +/// The grants still missing; `None` when the check itself failed. +async fn missing() -> Option> { + tokio::task::spawn_blocking(system_permission::missing) + .await + .ok() +} + +pub(super) async fn ensure( + owner: &Computer, + call: &Scope, + basis: PermissionBasis, + native_operation_id: &str, + cancellation: &CancellationToken, + cell: &maka_code_runtime::CellContext, +) -> Result<(), ToolError> { + // A failed check leaves the native call to report what it meets. + let grants = missing().await.unwrap_or_default(); + if grants.is_empty() { + return Ok(()); + } + let application = tokio::task::spawn_blocking({ + let grants = grants.clone(); + move || { + system_permission::request(&grants); + system_permission::application() + } + }) + .await + .map_err(|_| failed("System permission check stopped"))?; + let still_missing = + |grants: &[SystemPermissionGrant]| failed(system_permission::failure(grants, &application)); + let (configuration, _) = access::current(owner, call, "cua_repl").await?; + if configuration.permission_basis() != basis { + return Err(failed("Computer Use permission boundary changed")); + } + if !configuration.permissions.request_permissions { + return Err(still_missing(&grants)); + } + let context = ToolCallContext { + execution: call.execution().cloned(), + invocation: call.identity.agent().expect("checked Agent").clone(), + operation_id: call.identity.operation_id().expect("checked tool").into(), + }; + let store = maka_agent_authority::permissions::execution( + &owner.store, + &context.invocation, + context.execution.as_deref(), + )?; + let request_id = format!( + "system_permission_{}", + hex::encode(Sha256::digest( + serde_json::to_vec(&( + &context.invocation, + &context.operation_id, + native_operation_id, + &grants + )) + .expect("trusted grant list") + )) + ); + let record = InteractionRecord { + session_id: context.invocation.session_id.clone(), + turn_id: context.invocation.turn_id.clone(), + run_id: context.invocation.run_id.clone(), + request_id, + created_at: maka_agent_authority::now(), + outcome: None, + request: InteractionRequest::SystemPermission { + tool_use_id: context.tool_use_id(), + grants: grants.clone(), + application: application.clone(), + }, + }; + let observed = async { + let committed = store + .establish_run_interaction(&context.invocation, &record) + .await + .map_err(|_| "System permission request publication unavailable")?; + if !committed.matches { + return Err("System permission request identity conflict"); + } + let _timeout = cell.suspend_timeout(); + let granted = async { + loop { + tokio::time::sleep(RECHECK).await; + if missing().await.is_some_and(|grants| grants.is_empty()) { + // The first outcome stands: a cancel committed meanwhile wins. + let _ = store + .commit_interaction_outcome( + &record.request_id, + InteractionOutcome::SystemPermissionGranted { + committed_at: maka_agent_authority::now(), + }, + ) + .await; + return; + } + } + }; + tokio::select! { + outcome = maka_agent_authority::interaction_wait::wait(&store, &record, cancellation) => outcome, + () = granted => maka_agent_authority::interaction_wait::wait(&store, &record, cancellation).await, + } + } + .await; + let outcome = match observed { + Ok(outcome) => outcome, + Err(error) => match maka_agent_authority::interaction_wait::withdraw(&store, &record).await + { + Ok(Some(outcome)) => outcome, + Ok(None) => return Err(failed(error)), + Err(error) => return Err(ToolError::CleanupUnconfirmed(error.into())), + }, + }; + if cancellation.is_cancelled() { + return Err(failed("System permission request cancelled")); + } + if matches!(outcome, InteractionOutcome::SystemPermissionGranted { .. }) { + return Ok(()); + } + // A cancel stands even if the grants arrived as the card closed. + match missing().await { + Some(missing) if !missing.is_empty() => Err(still_missing(&missing)), + Some(_) => Err(failed("System permission request cancelled")), + None => Err(still_missing(&grants)), + } +} diff --git a/products/maka/acp/src/foreground.rs b/products/maka/acp/src/foreground.rs index 4ef08f79bec..36d9e808159 100644 --- a/products/maka/acp/src/foreground.rs +++ b/products/maka/acp/src/foreground.rs @@ -137,6 +137,8 @@ impl Foreground { self.refresh().await?; let mut callbacks = JoinSet::>::new(); let mut seen = HashSet::new(); + // Callbacks by request, so one settled elsewhere can be dropped. + let mut live = HashMap::::new(); let mut pending = Vec::::new(); let mut heartbeat = tokio::time::interval(Duration::from_secs(15)); heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -179,7 +181,8 @@ impl Foreground { let client = self.client.clone(); let peer = self.peer.clone(); let cancel = self.active.cancel.clone(); - callbacks.spawn(async move { + let request_id = record.request_id.clone(); + let handle = callbacks.spawn(async move { let response = peer.request(callback, &cancel).await?; let current: InteractionRecord = client.query("agent", "interaction", json!({"sessionId":record.session_id,"requestId":record.request_id})).await?; if current.outcome.is_some() { return Ok(()); } @@ -187,6 +190,7 @@ impl Foreground { let result: InteractionResult = client.mutate("agent", "respond", json!({"sessionId":current.session_id,"requestId":current.request_id,"answer":answer}), Attachments::new()).await?; match result { InteractionResult::Recorded { record } if record.outcome.is_some() => Ok(()), _ => Err("Host rejected interaction response".into()) } }); + live.insert(request_id, handle); self.state(acp::StateUpdate::RequiresAction( acp::RequiresActionStateUpdate::new(), )) @@ -206,7 +210,12 @@ impl Foreground { }, result = &mut next => break Some(result?), callback = callbacks.join_next(), if !callbacks.is_empty() => { - callback.ok_or("ACP callback task missing")???; + match callback.ok_or("ACP callback task missing")? { + Ok(result) => result?, + // Settled elsewhere; see `live`. + Err(error) if error.is_cancelled() => {} + Err(error) => return Err(error.into()), + } if callbacks.is_empty() { self.state(acp::StateUpdate::Running(acp::RunningStateUpdate::new())).await?; } }, _ = heartbeat.tick() => { @@ -219,7 +228,21 @@ impl Foreground { continue; }; match page { - DeliveryPage::Interactions { pending: update } => pending = update.entries, + DeliveryPage::Interactions { pending: update } => { + // An interaction settled without this client, such as one + // its producer resolved, no longer waits for its answer. + live.retain(|request_id, handle| { + let waiting = update + .entries + .iter() + .any(|entry| &entry.request_id == request_id); + if !waiting { + handle.abort(); + } + waiting + }); + pending = update.entries; + } page => self.frame(page).await?, } } diff --git a/products/maka/acp/src/interaction.rs b/products/maka/acp/src/interaction.rs index 668ece510cd..93fa408bca0 100644 --- a/products/maka/acp/src/interaction.rs +++ b/products/maka/acp/src/interaction.rs @@ -47,7 +47,8 @@ pub fn request( InteractionRequest::ServiceApproval { .. } | InteractionRequest::Permissions { .. } | InteractionRequest::ClientCapability { .. } - | InteractionRequest::ComputerApp { .. } => permission::request(snapshot), + | InteractionRequest::ComputerApp { .. } + | InteractionRequest::SystemPermission { .. } => permission::request(snapshot), InteractionRequest::Form { .. } | InteractionRequest::Question { .. } => { form::request(snapshot, capabilities) } @@ -66,7 +67,8 @@ pub fn answer( InteractionRequest::ServiceApproval { .. } | InteractionRequest::Permissions { .. } | InteractionRequest::ClientCapability { .. } - | InteractionRequest::ComputerApp { .. }, + | InteractionRequest::ComputerApp { .. } + | InteractionRequest::SystemPermission { .. }, Response::Permission(response), ) => permission::answer(snapshot, response)?, ( diff --git a/products/maka/acp/src/interaction/permission.rs b/products/maka/acp/src/interaction/permission.rs index ee12c91a984..f4e5869534d 100644 --- a/products/maka/acp/src/interaction/permission.rs +++ b/products/maka/acp/src/interaction/permission.rs @@ -24,6 +24,7 @@ use maka_agent_facts::interaction::InteractionRecord; use maka_agent_facts::interaction::InteractionRequest; use maka_agent_facts::interaction::{ ComputerAppDecision, ComputerAppPlatform, ComputerAppRisk, GrantCapability, GrantScope, + SystemPermissionGrant, }; use maka_sandbox::grant; @@ -227,6 +228,41 @@ pub(super) fn request(snapshot: &InteractionRecord) -> Result { + let names = grants + .iter() + .map(|grant| match grant { + SystemPermissionGrant::Accessibility => "Accessibility", + SystemPermissionGrant::ScreenRecording => "Screen Recording", + }) + .collect::>() + .join(" and "); + Ok(Callback::Permission( + acp::RequestPermissionRequest::new( + acp::SessionId::new(snapshot.session_id.as_str()), + format!("{application} needs {names}"), + vec![acp::PermissionOption::new( + "reject_once", + "Cancel", + acp::PermissionOptionKind::RejectOnce, + )], + ) + .description(format!( + "Grant {names} to {application} in System Settings > Privacy & Security. \ + The tool call continues automatically once the access is granted." + )) + .subject(acp::RequestPermissionSubject::from( + acp::ToolCallUpdate::new(acp::ToolCallId::new(tool_use_id.as_str())) + .raw_input(serde_json::to_value(&snapshot.request)?), + )), + )) + } _ => Err("Expected a permission interaction".into()), } } @@ -327,6 +363,20 @@ pub(super) fn answer( }; InteractionAnswer::ComputerApp { decision } } + InteractionRequest::SystemPermission { .. } => match response.outcome { + acp::RequestPermissionOutcome::Cancelled => { + InteractionAnswer::SystemPermissionCancel {} + } + acp::RequestPermissionOutcome::Selected(selected) + if selected.option_id.0.as_ref() == "reject_once" => + { + InteractionAnswer::SystemPermissionCancel {} + } + acp::RequestPermissionOutcome::Selected(_) => { + return Err("Client selected an unoffered system permission option".into()); + } + _ => return Err("Unsupported ACP permission outcome".into()), + }, _ => return Err("Expected a permission interaction".into()), }) } @@ -495,6 +545,70 @@ mod tests { } } + fn system_permission() -> InteractionRecord { + snapshot(InteractionRequest::SystemPermission { + tool_use_id: "tool_system".into(), + grants: vec![ + SystemPermissionGrant::Accessibility, + SystemPermissionGrant::ScreenRecording, + ], + application: "Maka Host".into(), + }) + } + + #[test] + fn system_permission_prompt_names_grants_and_offers_only_cancel() { + let record = system_permission(); + let Callback::Permission(callback) = + request(&record, &acp::ClientCapabilities::default()).unwrap() + else { + panic!("permission callback"); + }; + assert_eq!( + callback.title, + "Maka Host needs Accessibility and Screen Recording" + ); + assert_eq!( + callback.description.as_deref(), + Some( + "Grant Accessibility and Screen Recording to Maka Host in System Settings > Privacy & Security. The tool call continues automatically once the access is granted." + ) + ); + assert_eq!( + serde_json::to_value(&callback.options).unwrap(), + json!([{"optionId":"reject_once","name":"Cancel","kind":"reject_once"}]) + ); + let Some(acp::RequestPermissionSubject::ToolCall(subject)) = callback.subject else { + panic!("tool subject"); + }; + let subject = serde_json::to_value(subject).unwrap(); + assert_eq!(subject["toolCall"]["toolCallId"], "tool_system"); + assert_eq!( + subject["toolCall"]["rawInput"]["grants"], + json!(["accessibility", "screen_recording"]) + ); + } + + #[test] + fn system_permission_answers_can_only_cancel() { + let record = system_permission(); + for response in [ + json!({"outcome":{"outcome":"cancelled"}}), + json!({"outcome":{"outcome":"selected","optionId":"reject_once"}}), + ] { + assert_eq!( + answer(&record, response).unwrap(), + InteractionAnswer::SystemPermissionCancel {} + ); + } + for response in [ + json!({"outcome":{"outcome":"selected","optionId":"allow_once"}}), + json!({"outcome":{"outcome":"_future_approval"}}), + ] { + assert!(answer(&record, response).is_err()); + } + } + #[test] fn computer_output_audio_is_named_and_remains_a_session_capability() { let InteractionRequest::ComputerApp { diff --git a/products/maka/workbench/app/InteractionComposer.tsx b/products/maka/workbench/app/InteractionComposer.tsx index e32b7a44b21..f518ae2a181 100644 --- a/products/maka/workbench/app/InteractionComposer.tsx +++ b/products/maka/workbench/app/InteractionComposer.tsx @@ -33,7 +33,7 @@ import { Input } from 'nsh/base'; import type { ViewRef } from 'nsh'; import { useHost } from './host'; import { keymap } from 'nsh'; -import { windowSize } from 'nsh'; +import { openUrl, windowSize } from 'nsh'; import { Button } from 'nsh/base'; import type { InteractionAnswer, @@ -66,6 +66,11 @@ import { Icon } from './Icon'; import { CapabilityRequest, computerCapability } from './CapabilityRequest'; import { FormRequest } from './FormRequest'; import { ComputerAppRequest } from './ComputerAppRequest'; +import { + SystemPermissionRequest, + systemPermissionName, + systemPermissionPane, +} from './SystemPermissionRequest'; import { computerAppOptions } from './interaction-computer-app'; import { formSubmission, @@ -183,6 +188,7 @@ export function interactionOutcomeSummary(outcome: InteractionOutcome): UiText { return outcome.decision === 'allow' ? uiMessage('Capability allowed') : uiMessage('Capability denied'); + if (outcome.kind === 'system_permission_granted') return uiMessage('System permissions granted'); if (outcome.kind === 'computer_app_decision') return outcome.decision === 'deny' ? uiMessage('Application access denied') @@ -256,6 +262,14 @@ export function InteractionComposer({ const current = request(); return current?.kind === 'computer_app' ? current : null; }; + const systemPermissionRequest = () => { + const current = request(); + return current?.kind === 'system_permission' ? current : null; + }; + // A cancel answer has a kind of its own; it applies to its request kind. + const cancels = (answer: InteractionAnswer) => + (answer.kind === 'computer_app_cancel' && !!computerAppRequest()) || + (answer.kind === 'system_permission_cancel' && !!systemPermissionRequest()); const sameRecord = (value: InteractionRecord | null | undefined) => value?.sessionId === sessionId && value?.requestId === requestId; const ownsAction = (owner: InteractionOwner) => @@ -265,7 +279,9 @@ export function InteractionComposer({ return current ? JSON.stringify(current.questions) : null; }; const responseIdentity = () => - formRequest() || capabilityRequest() || computerAppRequest() ? JSON.stringify(request()) : null; + formRequest() || capabilityRequest() || computerAppRequest() || systemPermissionRequest() + ? JSON.stringify(request()) + : null; const form = () => formSubmission(formRequest()?.fields ?? [], formDrafts()); const changeForm = ( choice: InteractionOwner & { @@ -489,12 +505,7 @@ export function InteractionComposer({ if (ownsAction(owner)) void refresh(); }; const respond = async (answer: InteractionAnswer) => { - if ( - blocked() || - (answer.kind !== request()?.kind && - !(answer.kind === 'computer_app_cancel' && computerAppRequest())) - ) - return; + if (blocked() || (answer.kind !== request()?.kind && !cancels(answer))) return; busy.set(true); error.set(''); try { @@ -578,6 +589,15 @@ export function InteractionComposer({ if (kind === 'computer_app') return { kind, decision: 'deny' }; return null; }; + const openSettings = async (url: string) => { + let opened = false; + try { + opened = (await openUrl(url)).opened; + } catch (reason) { + console.warn('System Settings did not open:', reason); + } + if (alive && !opened) error.set(tr('System Settings could not be opened.')); + }; const deny = () => { const answer = denialAnswer(); if (answer) void respond(answer); @@ -592,16 +612,17 @@ export function InteractionComposer({ ) => { if (!ownsAction(rendered) || blocked()) return; const answer = JSON.parse(rendered.answerJSON) as InteractionAnswer | null; - if ( - !answer || - (answer.kind !== request()?.kind && - !(answer.kind === 'computer_app_cancel' && computerAppRequest())) - ) - return; + if (!answer || (answer.kind !== request()?.kind && !cancels(answer))) return; if (answer.kind === 'question' && rendered.questionsJSON !== questionIdentity()) return; if (answer.kind === 'service_approval' && rendered.sourceJSON !== sourceIdentity()) return; if ( - ['form', 'client_capability', 'computer_app', 'computer_app_cancel'].includes(answer.kind) && + [ + 'form', + 'client_capability', + 'computer_app', + 'computer_app_cancel', + 'system_permission_cancel', + ].includes(answer.kind) && rendered.requestJSON !== responseIdentity() ) return; @@ -668,6 +689,7 @@ export function InteractionComposer({ form: tr('Form request'), client_capability: tr('Capability request'), computer_app: tr('Application access request'), + system_permission: tr('System permission request'), }[current.kind] : tr('Review request'); }; @@ -993,6 +1015,9 @@ export function InteractionComposer({ + + + } > @@ -1067,6 +1092,22 @@ export function InteractionComposer({ gap: 8, }} > + + + + {(grant) => ( + void openSettings(systemPermissionPane(grant()))} + /> + )} + + ; + +/** The System Settings pane that holds each grant. */ +const panes: Record = { + accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', + screen_recording: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture', +}; + +export function systemPermissionPane(grant: SystemPermissionGrant): string { + return panes[grant]; +} + +/** The grant's name as System Settings shows it. */ +export function systemPermissionName(grant: SystemPermissionGrant): string { + return grant === 'accessibility' ? tr('Accessibility') : tr('Screen & System Audio Recording'); +} + +export function SystemPermissionRequest(props: { request: SystemPermissionRequest }) { + return ( + + + {tr( + '{application} needs these macOS permissions to use the computer. Turn them on in System Settings; the request continues on its own once they are on.', + { application: props.request.application }, + )} + + + + {(grant) => ( + + {systemPermissionName(grant())} + + )} + + + + {tr( + 'If {application} is not listed, reopen System Settings, or add it with the + button.', + { + application: props.request.application, + }, + )} + + + ); +} diff --git a/products/maka/workbench/app/locale-conversation.ts b/products/maka/workbench/app/locale-conversation.ts index 9e47d6baebc..7746fa689cc 100644 --- a/products/maka/workbench/app/locale-conversation.ts +++ b/products/maka/workbench/app/locale-conversation.ts @@ -1568,6 +1568,31 @@ export const conversationMessages = { '此對話已變更。請核對目前沙箱設定後重新選擇。', ], 'Application access request': ['Application access request', '应用访问请求', '應用程式存取請求'], + 'System permission request': ['System permission request', '系统权限请求', '系統權限請求'], + '{application} needs these macOS permissions to use the computer. Turn them on in System Settings; the request continues on its own once they are on.': + [ + '{application} needs these macOS permissions to use the computer. Turn them on in System Settings; the request continues on its own once they are on.', + '{application} 需要以下 macOS 权限才能操作电脑。请在系统设置中打开,打开后请求会自动继续。', + '{application} 需要以下 macOS 權限才能操作電腦。請在系統設定中開啟,開啟後請求會自動繼續。', + ], + Accessibility: ['Accessibility', '辅助功能', '輔助使用'], + 'Screen & System Audio Recording': [ + 'Screen & System Audio Recording', + '录屏与系统录音', + '螢幕與系統錄音', + ], + 'If {application} is not listed, reopen System Settings, or add it with the + button.': [ + 'If {application} is not listed, reopen System Settings, or add it with the + button.', + '如果列表中没有 {application},请重新打开系统设置,或用 + 按钮添加。', + '如果清單中沒有 {application},請重新打開系統設定,或用 + 按鈕加入。', + ], + 'Open {name} settings': ['Open {name} settings', '打开{name}设置', '開啟{name}設定'], + 'System Settings could not be opened.': [ + 'System Settings could not be opened.', + '无法打开系统设置。', + '無法開啟系統設定。', + ], + 'System permissions granted': ['System permissions granted', '系统权限已开启', '系統權限已開啟'], 'Application access denied': [ 'Application access denied', '已拒绝应用访问', From fc32b920b10db7882d8652e6cbfbb641902759d5 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Tue, 3 Nov 2026 10:38:22 +0800 Subject: [PATCH 4/6] fix(computer-use): offload macOS permission probes Generated-by: Codex --- platform/computer-use/src/macos.rs | 8 +++++++- .../src/macos/permission_subject.rs | 2 +- platform/computer-use/src/session.rs | 20 ++++++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/platform/computer-use/src/macos.rs b/platform/computer-use/src/macos.rs index 7bf46c2b70f..4c3abbbcc03 100644 --- a/platform/computer-use/src/macos.rs +++ b/platform/computer-use/src/macos.rs @@ -56,7 +56,13 @@ unsafe extern "C" { fn CGSessionCopyCurrentDictionary() -> core_foundation::dictionary::CFDictionaryRef; } -pub(crate) fn require_desktop() -> Result<(), ToolError> { +pub(crate) async fn require_desktop() -> Result<(), ToolError> { + tokio::task::spawn_blocking(require_desktop_blocking) + .await + .map_err(failed)? +} + +pub(crate) fn require_desktop_blocking() -> Result<(), ToolError> { use core_foundation::{ boolean::{CFBooleanGetTypeID, kCFBooleanTrue}, dictionary::CFDictionaryGetValue, diff --git a/platform/computer-use/src/macos/permission_subject.rs b/platform/computer-use/src/macos/permission_subject.rs index 8f40a898b1a..cae4f8d6d18 100644 --- a/platform/computer-use/src/macos/permission_subject.rs +++ b/platform/computer-use/src/macos/permission_subject.rs @@ -34,7 +34,7 @@ pub(crate) enum Request { impl Request { pub(crate) fn resolve(self, language: Language) -> Result { - super::require_desktop()?; + super::require_desktop_blocking()?; let (app, binding) = match self { Self::RawApp(name) => { let selected = raw_app_candidate(&name, &apps::list_all_apps())?; diff --git a/platform/computer-use/src/session.rs b/platform/computer-use/src/session.rs index 5f693648299..c5ceeb65445 100644 --- a/platform/computer-use/src/session.rs +++ b/platform/computer-use/src/session.rs @@ -356,7 +356,13 @@ impl Session { .invoke_in_turn(command, invocation, cancellation, language) .await; #[cfg(target_os = "macos")] - let result = result.map_err(crate::macos::tcc::explain); + if let Err(error) = result { + return Err( + tokio::task::spawn_blocking(move || crate::macos::tcc::explain(error)) + .await + .map_err(failed)?, + ); + } result } } @@ -455,7 +461,7 @@ impl Session { Command::GetState { .. } => { let (browsers, mut errors) = self.browsers.state().await; #[cfg(target_os = "macos")] - if let Err(error) = crate::macos::require_desktop() { + if let Err(error) = crate::macos::require_desktop().await { errors.push(error.to_string()); } let apps = match self.apps(session, cancellation, language).await { @@ -485,7 +491,7 @@ impl Session { } Command::GetApp { target } => { #[cfg(target_os = "macos")] - crate::macos::require_desktop()?; + crate::macos::require_desktop().await?; #[cfg(target_os = "macos")] let expected = invocation.app_target().cloned(); #[cfg(target_os = "macos")] @@ -929,7 +935,7 @@ impl Session { }) })?; #[cfg(target_os = "macos")] - crate::macos::require_desktop()?; + crate::macos::require_desktop().await?; let pid = target .window .pid @@ -1045,7 +1051,11 @@ impl Session { self.verify(handle, session, cancellation, language).await?; // A macOS observation always captures the window image. #[cfg(target_os = "macos")] - crate::macos::tcc::require(crate::macos::tcc::Grant::ScreenRecording)?; + tokio::task::spawn_blocking(|| { + crate::macos::tcc::require(crate::macos::tcc::Grant::ScreenRecording) + }) + .await + .map_err(failed)??; let ax = !matches!(kind, ObservationKind::Screenshot); let screenshot = !matches!(kind, ObservationKind::Ax); let capture_image = screenshot || cfg!(target_os = "macos"); From d1744d20020e57e90a2d7aefc76374757e3ede4c Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Tue, 3 Nov 2026 10:38:54 +0800 Subject: [PATCH 5/6] test(packaging): avoid pinning the Host helper version Generated-by: Codex --- scripts/rust/build-cli.test.mjs | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/rust/build-cli.test.mjs b/scripts/rust/build-cli.test.mjs index 585dff744d0..55af3dd24f4 100644 --- a/scripts/rust/build-cli.test.mjs +++ b/scripts/rust/build-cli.test.mjs @@ -177,13 +177,10 @@ test('CLI builds use the requested target and artifact path', async (t) => { if (process.platform !== 'win32') assert.equal((await lstat(join(host, 'MacOS/maka-host-shell'))).mode & 0o777, 0o755); const hostPlist = await readFile(join(host, 'Info.plist'), 'utf8'); - // The helper keeps the same Info.plist across releases. for (const [key, value] of [ ['CFBundleExecutable', 'maka-host-shell'], ['CFBundleIdentifier', 'org.apache.maka.host'], ['CFBundleName', 'Maka Host'], - ['CFBundleShortVersionString', '1'], - ['CFBundleVersion', '1'], ]) assert.ok(hostPlist.includes(`${key}\n ${value}`), key); assert.ok(hostPlist.includes('LSUIElement\n ')); From b3c4b55a6a056d0b34b60b66f429729a50c72db4 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Tue, 3 Nov 2026 10:52:23 +0800 Subject: [PATCH 6/6] fix(computer-use): request only required system permissions Keep permission waits cancellable without arbitrary ACP deadlines or count limits. Propagate producer outcome persistence failures through the existing cleanup path. Generated-by: Codex --- packages/plugin-sdk/src/contracts.ts | 2 - .../computer-use/src/system_permission.rs | 105 ++++++++++++++++-- plugins/agent/facts/src/interaction.rs | 2 +- plugins/agent/facts/src/interaction/tests.rs | 1 - .../agent/facts/src/interaction/validation.rs | 4 - .../workspace-computer/native/src/evaluate.rs | 19 ++-- .../native/src/system_permission.rs | 29 +++-- products/maka/acp/src/foreground.rs | 3 - products/maka/acp/src/transport.rs | 4 +- 9 files changed, 128 insertions(+), 41 deletions(-) diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts index d76a27a1256..c88f0967a66 100644 --- a/packages/plugin-sdk/src/contracts.ts +++ b/packages/plugin-sdk/src/contracts.ts @@ -9673,7 +9673,6 @@ export const contracts = { 'The application name the operating system lists in its settings.', type: 'string', minLength: 1, - maxLength: 256, }, }, additionalProperties: false, @@ -16141,7 +16140,6 @@ export const contracts = { 'The application name the operating system lists in its settings.', type: 'string', minLength: 1, - maxLength: 256, }, }, additionalProperties: false, diff --git a/platform/computer-use/src/system_permission.rs b/platform/computer-use/src/system_permission.rs index d2fbb0eace8..0a3f952b14d 100644 --- a/platform/computer-use/src/system_permission.rs +++ b/platform/computer-use/src/system_permission.rs @@ -22,23 +22,75 @@ //! On macOS these are Accessibility and Screen Recording, which belong to //! the application responsible for the Host. Other platforms need none. +use crate::protocol::{Command, Handle, NativeComputerCall, NativeComputerMethod}; use maka_agent_facts::interaction::SystemPermissionGrant; +/// macOS grants used by this command. Inventory, documentation and browser +/// work remain usable before the user grants native control or capture. +pub fn required(command: &Command) -> &'static [SystemPermissionGrant] { + use SystemPermissionGrant::{Accessibility, ScreenRecording}; + match command { + Command::GetApp { .. } + | Command::Observe { + handle: Handle::App(_), + .. + } + | Command::NativeComputer { + call: + NativeComputerCall::Invoke { + operation: NativeComputerMethod::GetAppState, + .. + }, + } => &[Accessibility, ScreenRecording], + Command::StartAudioRecording { .. } => &[ScreenRecording], + Command::NativeComputer { + call: + NativeComputerCall::Invoke { + operation: NativeComputerMethod::ListApps, + .. + }, + } => &[], + Command::NativeComputer { + call: NativeComputerCall::Invoke { .. }, + } + | Command::Action { + handle: Handle::App(_), + .. + } + | Command::ClipboardRead { + handle: Handle::App(_), + } + | Command::ClipboardReadText { + handle: Handle::App(_), + } + | Command::ClipboardWrite { + handle: Handle::App(_), + .. + } + | Command::ClipboardWriteText { + handle: Handle::App(_), + .. + } => &[Accessibility], + _ => &[], + } +} + /// The grants native desktop work needs that are not in effect now, checked /// with real calls. It blocks for those calls. -pub fn missing() -> Vec { +pub fn missing(required: &[SystemPermissionGrant]) -> Vec { #[cfg(all(feature = "native-desktop", target_os = "macos"))] { - [ - SystemPermissionGrant::Accessibility, - SystemPermissionGrant::ScreenRecording, - ] - .into_iter() - .filter(|grant| !crate::macos::tcc::present((*grant).into())) - .collect() + required + .iter() + .copied() + .filter(|grant| !crate::macos::tcc::present((*grant).into())) + .collect() } #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] - Vec::new() + { + let _ = required; + Vec::new() + } } /// Asks the operating system to show its own request for each grant, at @@ -79,3 +131,38 @@ pub fn failure(grants: &[SystemPermissionGrant], application: &str) -> String { String::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::AppReference; + + #[test] + fn browser_setup_and_documentation_do_not_require_native_grants() { + for input in [ + r#"{"method":"readDocumentation","name":"browser"}"#, + r#"{"method":"configureCursor","options":{"enabled":false}}"#, + r#"{"method":"cursorState","options":{}}"#, + r#"{"method":"listBrowsers","options":{}}"#, + r#"{"method":"stopAudioRecording"}"#, + ] { + let command = serde_json::from_str(input).unwrap(); + assert_eq!(required(&command), &[], "{input}"); + } + assert_eq!( + required(&Command::GetApp { + target: AppReference::Name("Notes".into()) + }), + &[ + SystemPermissionGrant::Accessibility, + SystemPermissionGrant::ScreenRecording + ], + ); + assert_eq!( + required(&Command::StartAudioRecording { + options: Default::default() + }), + &[SystemPermissionGrant::ScreenRecording], + ); + } +} diff --git a/plugins/agent/facts/src/interaction.rs b/plugins/agent/facts/src/interaction.rs index 125f05bc5b5..12bba096863 100644 --- a/plugins/agent/facts/src/interaction.rs +++ b/plugins/agent/facts/src/interaction.rs @@ -220,7 +220,7 @@ pub enum InteractionRequest { #[schemars(length(min = 1))] grants: Vec, /// The application name the operating system lists in its settings. - #[schemars(length(min = 1, max = 256))] + #[schemars(length(min = 1))] application: String, }, } diff --git a/plugins/agent/facts/src/interaction/tests.rs b/plugins/agent/facts/src/interaction/tests.rs index 98b37da2747..0ae17e4ed92 100644 --- a/plugins/agent/facts/src/interaction/tests.rs +++ b/plugins/agent/facts/src/interaction/tests.rs @@ -175,7 +175,6 @@ fn system_permission_request_round_trips_and_validates_grants_and_application() json!(" Maka Host"), json!("Maka\nHost"), json!("Maka\0Host"), - json!("a".repeat(257)), json!(null), ] { let mut input = system_permission_request(); diff --git a/plugins/agent/facts/src/interaction/validation.rs b/plugins/agent/facts/src/interaction/validation.rs index 6fa7499084a..7b572b443eb 100644 --- a/plugins/agent/facts/src/interaction/validation.rs +++ b/plugins/agent/facts/src/interaction/validation.rs @@ -21,9 +21,6 @@ use super::*; use crate::capability::FormInput; pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; -/// Upper bound for the operating-system application name shown on a system permission card; -/// the wire schema declares the same bound. -const MAX_SYSTEM_PERMISSION_APPLICATION_CHARS: usize = 256; pub fn entity_id(value: &str) -> Result<(), &'static str> { if value.is_empty() @@ -272,7 +269,6 @@ impl InteractionRequest { } if application.trim().is_empty() || application.trim() != application - || application.chars().count() > MAX_SYSTEM_PERMISSION_APPLICATION_CHARS || application.chars().any(char::is_control) { return Err("Invalid system permission application name"); diff --git a/plugins/workspace/workspace-computer/native/src/evaluate.rs b/plugins/workspace/workspace-computer/native/src/evaluate.rs index 75eb0c381e4..c19f5eeaa33 100644 --- a/plugins/workspace/workspace-computer/native/src/evaluate.rs +++ b/plugins/workspace/workspace-computer/native/src/evaluate.rs @@ -372,13 +372,18 @@ impl Computer { } targets.push(api::audio_target()); } - // Native desktop work needs the operating system's privacy grants; - // resolving an app below already uses them. - if !browser_command(&command) - && !matches!(command, maka_computer_use::protocol::Command::Documentation) - { - system_permission::ensure(self, &call, basis, &operation_id, &cancellation, &cell) - .await?; + let required = maka_computer_use::system_permission::required(&command); + if !required.is_empty() { + system_permission::ensure( + self, + &call, + basis, + required, + &operation_id, + &cancellation, + &cell, + ) + .await?; } let subject = { let mut desktop = tokio::select! { biased; diff --git a/plugins/workspace/workspace-computer/native/src/system_permission.rs b/plugins/workspace/workspace-computer/native/src/system_permission.rs index a13407cdd25..42fb684a657 100644 --- a/plugins/workspace/workspace-computer/native/src/system_permission.rs +++ b/plugins/workspace/workspace-computer/native/src/system_permission.rs @@ -36,8 +36,8 @@ use sha2::{Digest, Sha256}; const RECHECK: std::time::Duration = std::time::Duration::from_millis(1500); /// The grants still missing; `None` when the check itself failed. -async fn missing() -> Option> { - tokio::task::spawn_blocking(system_permission::missing) +async fn missing(required: &'static [SystemPermissionGrant]) -> Option> { + tokio::task::spawn_blocking(move || system_permission::missing(required)) .await .ok() } @@ -46,12 +46,13 @@ pub(super) async fn ensure( owner: &Computer, call: &Scope, basis: PermissionBasis, + required: &'static [SystemPermissionGrant], native_operation_id: &str, cancellation: &CancellationToken, cell: &maka_code_runtime::CellContext, ) -> Result<(), ToolError> { // A failed check leaves the native call to report what it meets. - let grants = missing().await.unwrap_or_default(); + let grants = missing(required).await.unwrap_or_default(); if grants.is_empty() { return Ok(()); } @@ -112,31 +113,35 @@ pub(super) async fn ensure( let committed = store .establish_run_interaction(&context.invocation, &record) .await - .map_err(|_| "System permission request publication unavailable")?; + .map_err(|error| format!("System permission request publication unavailable: {error}"))?; if !committed.matches { - return Err("System permission request identity conflict"); + return Err("System permission request identity conflict".into()); } let _timeout = cell.suspend_timeout(); let granted = async { loop { tokio::time::sleep(RECHECK).await; - if missing().await.is_some_and(|grants| grants.is_empty()) { + if missing(required).await.is_some_and(|grants| grants.is_empty()) { // The first outcome stands: a cancel committed meanwhile wins. - let _ = store + store .commit_interaction_outcome( &record.request_id, InteractionOutcome::SystemPermissionGranted { committed_at: maka_agent_authority::now(), }, ) - .await; - return; + .await + .map_err(|error| format!("System permission outcome unavailable: {error}"))?; + return Ok::<_, String>(()); } } }; tokio::select! { - outcome = maka_agent_authority::interaction_wait::wait(&store, &record, cancellation) => outcome, - () = granted => maka_agent_authority::interaction_wait::wait(&store, &record, cancellation).await, + outcome = maka_agent_authority::interaction_wait::wait(&store, &record, cancellation) => outcome.map_err(str::to_owned), + result = granted => { + result?; + maka_agent_authority::interaction_wait::wait(&store, &record, cancellation).await.map_err(str::to_owned) + }, } } .await; @@ -156,7 +161,7 @@ pub(super) async fn ensure( return Ok(()); } // A cancel stands even if the grants arrived as the card closed. - match missing().await { + match missing(required).await { Some(missing) if !missing.is_empty() => Err(still_missing(&missing)), Some(_) => Err(failed("System permission request cancelled")), None => Err(still_missing(&grants)), diff --git a/products/maka/acp/src/foreground.rs b/products/maka/acp/src/foreground.rs index 36d9e808159..a1bc501fa40 100644 --- a/products/maka/acp/src/foreground.rs +++ b/products/maka/acp/src/foreground.rs @@ -173,9 +173,6 @@ impl Foreground { { continue; } - if seen.len() >= 1024 || callbacks.len() >= 16 { - return Err("ACP interaction limit exceeded".into()); - } seen.insert(record.request_id.clone()); let callback = interaction::request(&record, &self.capabilities)?; let client = self.client.clone(); diff --git a/products/maka/acp/src/transport.rs b/products/maka/acp/src/transport.rs index 41ce2284cc3..ddfa3c69a66 100644 --- a/products/maka/acp/src/transport.rs +++ b/products/maka/acp/src/transport.rs @@ -80,12 +80,12 @@ impl Peer { tokio::select! { _ = self.stop.cancelled() => Err("ACP connection closed".into()), _ = cancel.cancelled() => Err("ACP interaction cancelled".into()), - result = tokio::time::timeout(Duration::from_secs(300), async { + result = async { Ok::<_, Error>(match callback { Callback::Permission(request) => Response::Permission(self.connection.send_request(request).block_task().await?), Callback::Elicitation(request) => Response::Elicitation(self.connection.send_request(request).block_task().await?), }) - }) => result?, + } => result, } } }