diff --git a/Cargo.lock b/Cargo.lock index 5ac973f3329..f98b928de56 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", @@ -7970,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/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..4c3abbbcc03 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}, @@ -55,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, @@ -79,7 +86,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/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/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..c5ceeb65445 100644 --- a/platform/computer-use/src/session.rs +++ b/platform/computer-use/src/session.rs @@ -352,8 +352,18 @@ 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")] + if let Err(error) = result { + return Err( + tokio::task::spawn_blocking(move || crate::macos::tcc::explain(error)) + .await + .map_err(failed)?, + ); + } + result } } @@ -451,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 { @@ -481,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")] @@ -925,7 +935,7 @@ impl Session { }) })?; #[cfg(target_os = "macos")] - crate::macos::require_desktop()?; + crate::macos::require_desktop().await?; let pid = target .window .pid @@ -1039,6 +1049,13 @@ impl Session { language: Language, ) -> Result { self.verify(handle, session, cancellation, language).await?; + // A macOS observation always captures the window image. + #[cfg(target_os = "macos")] + 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"); 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 13fcb342504..451671e415d 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..55af3dd24f4 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,21 @@ 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'); + for (const [key, value] of [ + ['CFBundleExecutable', 'maka-host-shell'], + ['CFBundleIdentifier', 'org.apache.maka.host'], + ['CFBundleName', 'Maka Host'], + ]) + 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 +197,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 +218,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 +237,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 +249,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 +277,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) => {