From 4e352b85ae3881e9e8e9e46ff1197c4c1540906d Mon Sep 17 00:00:00 2001 From: Xun Lu Date: Mon, 2 Nov 2026 21:53:10 +0800 Subject: [PATCH 1/4] 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/4] 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 fc32b920b10db7882d8652e6cbfbb641902759d5 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Tue, 3 Nov 2026 10:38:22 +0800 Subject: [PATCH 3/4] 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 4/4] 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 '));