diff --git a/crates/tty7-core/src/daemon/mod.rs b/crates/tty7-core/src/daemon/mod.rs index c30317fe..e6b4b3dd 100644 --- a/crates/tty7-core/src/daemon/mod.rs +++ b/crates/tty7-core/src/daemon/mod.rs @@ -15,6 +15,9 @@ pub mod procinfo; pub mod protocol; pub(crate) mod remote; pub mod remote_link; +/// Disclaiming the launcher's privacy attribution at startup (#909). +#[cfg(target_os = "macos")] +pub mod responsibility; pub mod router; pub mod scrollback; pub mod server; diff --git a/crates/tty7-core/src/daemon/responsibility.rs b/crates/tty7-core/src/daemon/responsibility.rs new file mode 100644 index 00000000..9d740688 --- /dev/null +++ b/crates/tty7-core/src/daemon/responsibility.rs @@ -0,0 +1,285 @@ +//! Making the daemon its own *responsible process* on macOS (#909). +//! +//! macOS attributes privacy decisions — Local Network, Camera, Microphone, +//! Location, Apple Events — to a process's responsible process, and that is +//! fixed when the process is spawned: it is inherited from whoever started it. +//! The daemon is started by the GUI, so it and every shell it forks answer to +//! that GUI. That holds only while the GUI lives. The daemon is built to outlive +//! it — an update relaunches the GUI and keeps the shells — and once the GUI +//! that launched it is gone, new panes are no longer attributed to tty7.app. +//! Local Network then falls back to judging each binary on its own, and a +//! Homebrew `node` or `python` gets `EHOSTUNREACH` on the LAN while `/usr/bin` +//! tools, which are exempt, keep working. +//! +//! The fix is to disclaim the inherited responsibility at startup. There is no +//! way to change it in a running process, so the daemon re-executes itself — +//! `posix_spawn` with `POSIX_SPAWN_SETEXEC`, which is `execve` with spawn +//! attributes: same pid, same descriptors, same session — with +//! `responsibility_spawnattrs_setdisclaim` set. The new image is then +//! responsible for itself, and it is tty7.app's own signed executable, so its +//! shells are attributed to tty7 however long it outlives any window. +//! +//! Doing it at the top of `run_daemon` covers every way a daemon image starts: +//! a fresh spawn from the GUI or `tty7-cli`, and the far side of a handoff, +//! which is how a daemon started by an older build gets repaired in place +//! without losing its panes. Shells forked *before* that keep the attribution +//! they were born with; only panes opened afterwards pick up the new one. +//! +//! `responsibility_*` is private SPI in libsystem (Chromium, LLDB and others +//! use it the same way), so both symbols are looked up at runtime. If either is +//! missing, or anything fails, the daemon carries on exactly as before. + +use std::ffi::{CStr, CString, OsStr}; +use std::os::unix::ffi::OsStrExt as _; + +/// Appended to the re-executed image's arguments. It stops a second attempt +/// when the first one did not take — without it, a kernel that accepts the +/// attribute but ignores it would have the daemon re-executing forever. An +/// argument rather than an environment variable so it is not inherited by +/// every shell the daemon starts. +pub const DISCLAIMED_FLAG: &str = "--responsibility-disclaimed"; + +type ResponsibleFor = unsafe extern "C" fn(libc::pid_t) -> libc::pid_t; +type SetDisclaim = unsafe extern "C" fn(*mut libc::posix_spawnattr_t, libc::c_int) -> libc::c_int; + +/// Re-execute this process with its inherited responsibility disclaimed, +/// unless it already answers for itself. Returns only when no re-exec +/// happened; on success this process is already the new image. +pub fn disclaim_inherited() { + let Some(responsible_for) = + (unsafe { lookup::(c"responsibility_get_pid_responsible_for_pid") }) + else { + log::debug!("responsibility SPI unavailable; the daemon keeps its launcher's attribution"); + return; + }; + let pid = unsafe { libc::getpid() }; + let responsible = unsafe { responsible_for(pid) }; + let args: Vec = std::env::args_os().collect(); + + if args.iter().any(|arg| arg == DISCLAIMED_FLAG) { + if responsible == pid { + log::info!("the daemon is its own responsible process"); + } else { + log::warn!( + "disclaiming responsibility did not take (responsible pid {responsible}); \ + panes stay attributed to the process that started the daemon" + ); + } + return; + } + if responsible == pid { + return; + } + + let Some(set_disclaim) = + (unsafe { lookup::(c"responsibility_spawnattrs_setdisclaim") }) + else { + log::debug!("responsibility_spawnattrs_setdisclaim unavailable"); + return; + }; + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(e) => { + log::warn!("could not locate own executable to disclaim responsibility: {e}"); + return; + } + }; + + let error = reexec(&exe, &args, set_disclaim); + log::warn!( + "could not re-exec {} to disclaim responsibility (responsible pid {responsible}): {error}", + exe.display() + ); +} + +/// `posix_spawn` with `POSIX_SPAWN_SETEXEC` and the disclaim attribute. Returns +/// only on failure. +fn reexec( + exe: &std::path::Path, + args: &[std::ffi::OsString], + set_disclaim: SetDisclaim, +) -> std::io::Error { + let Some(path) = c_string(exe.as_os_str()) else { + return std::io::Error::other("executable path contains a NUL"); + }; + let mut argv: Vec = Vec::with_capacity(args.len() + 1); + for arg in args { + match c_string(arg) { + Some(arg) => argv.push(arg), + None => return std::io::Error::other("an argument contains a NUL"), + } + } + argv.push(CString::new(DISCLAIMED_FLAG).expect("no NUL in a literal")); + // `vars_os` skips entries that are not `KEY=VALUE`; nothing the daemon + // reads depends on those. + let envp: Vec = std::env::vars_os() + .filter_map(|(key, value)| { + let mut entry = key.as_bytes().to_vec(); + entry.push(b'='); + entry.extend_from_slice(value.as_bytes()); + CString::new(entry).ok() + }) + .collect(); + let argv_ptrs = null_terminated(&argv); + let envp_ptrs = null_terminated(&envp); + + unsafe { + let mut attr: libc::posix_spawnattr_t = std::ptr::null_mut(); + let rc = libc::posix_spawnattr_init(&mut attr); + if rc != 0 { + return std::io::Error::from_raw_os_error(rc); + } + // No other flags on purpose: the signal mask, signal dispositions and + // every descriptor without FD_CLOEXEC cross exactly as they would an + // `execve`, which is what a handoff's ptys and blob rely on. + let rc = + libc::posix_spawnattr_setflags(&mut attr, libc::POSIX_SPAWN_SETEXEC as libc::c_short); + if rc != 0 { + libc::posix_spawnattr_destroy(&mut attr); + return std::io::Error::from_raw_os_error(rc); + } + let rc = set_disclaim(&mut attr, 1); + if rc != 0 { + libc::posix_spawnattr_destroy(&mut attr); + return std::io::Error::from_raw_os_error(rc); + } + let mut child: libc::pid_t = 0; + let rc = libc::posix_spawn( + &mut child, + path.as_ptr(), + std::ptr::null(), + &attr, + argv_ptrs.as_ptr(), + envp_ptrs.as_ptr(), + ); + libc::posix_spawnattr_destroy(&mut attr); + std::io::Error::from_raw_os_error(rc) + } +} + +unsafe fn lookup(name: &CStr) -> Option { + let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()) }; + if symbol.is_null() { + return None; + } + debug_assert_eq!( + std::mem::size_of::(), + std::mem::size_of::<*mut libc::c_void>() + ); + Some(unsafe { std::mem::transmute_copy::<*mut libc::c_void, F>(&symbol) }) +} + +fn c_string(s: &OsStr) -> Option { + CString::new(s.as_bytes()).ok() +} + +fn null_terminated(strings: &[CString]) -> Vec<*mut libc::c_char> { + strings + .iter() + .map(|s| s.as_ptr() as *mut libc::c_char) + .chain(std::iter::once(std::ptr::null_mut())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A shell spawned with the attribute disclaimed becomes the responsible + /// process for what *it* starts — which is the daemon's situation exactly: + /// the daemon disclaims, its shells inherit from it. Without the attribute + /// the grandchild answers to whatever this test binary answers to, never to + /// the shell. Checked through a grandchild because a process whose chain + /// never had a responsible process reports itself, which would make a + /// direct check pass whether or not the attribute did anything. + #[test] + fn a_disclaimed_spawn_is_responsible_for_its_children() { + let (Some(responsible_for), Some(set_disclaim)) = (unsafe { + ( + lookup::(c"responsibility_get_pid_responsible_for_pid"), + lookup::(c"responsibility_spawnattrs_setdisclaim"), + ) + }) else { + eprintln!("responsibility SPI unavailable on this macOS; nothing to check"); + return; + }; + + let dir = std::env::temp_dir().join(format!("tty7-responsibility-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + // (shell pid, pid of the `sleep` the shell started, its responsible pid) + let run = |disclaim: bool| -> (libc::pid_t, libc::pid_t, libc::pid_t) { + let pidfile = dir.join(if disclaim { "disclaimed" } else { "plain" }); + let script = CString::new(format!( + "/bin/sleep 30 & echo $! > '{}'; wait", + pidfile.display() + )) + .unwrap(); + let sh = c"/bin/sh"; + let argv = [ + sh.as_ptr() as *mut libc::c_char, + c"-c".as_ptr() as *mut _, + script.as_ptr() as *mut _, + std::ptr::null_mut(), + ]; + let envp = [std::ptr::null_mut()]; + let shell = unsafe { + let mut attr: libc::posix_spawnattr_t = std::ptr::null_mut(); + assert_eq!(libc::posix_spawnattr_init(&mut attr), 0); + if disclaim { + assert_eq!(set_disclaim(&mut attr, 1), 0); + } + let mut pid = 0; + let rc = libc::posix_spawn( + &mut pid, + sh.as_ptr(), + std::ptr::null(), + &attr, + argv.as_ptr(), + envp.as_ptr(), + ); + libc::posix_spawnattr_destroy(&mut attr); + assert_eq!(rc, 0, "posix_spawn failed"); + pid + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let grandchild = loop { + if let Some(pid) = std::fs::read_to_string(&pidfile) + .ok() + .and_then(|text| text.trim().parse::().ok()) + { + break pid; + } + if std::time::Instant::now() > deadline { + unsafe { + libc::kill(shell, libc::SIGKILL); + libc::waitpid(shell, std::ptr::null_mut(), 0); + } + panic!("the shell never reported its child"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + let responsible = unsafe { responsible_for(grandchild) }; + unsafe { + libc::kill(grandchild, libc::SIGKILL); + libc::kill(shell, libc::SIGKILL); + libc::waitpid(shell, std::ptr::null_mut(), 0); + } + (shell, grandchild, responsible) + }; + + let (plain_shell, _, plain_responsible) = run(false); + let (disclaimed_shell, _, disclaimed_responsible) = run(true); + let _ = std::fs::remove_dir_all(&dir); + + assert_ne!( + plain_responsible, plain_shell, + "without the attribute the shell is not responsible for its children" + ); + assert_eq!( + disclaimed_responsible, disclaimed_shell, + "a disclaimed shell is responsible for its children" + ); + } +} diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 6c5d7992..d2a43c73 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -306,6 +306,11 @@ macro_rules! startup_note { } pub fn run_daemon() -> anyhow::Result<()> { + // First, before a handoff is adopted: the re-exec keeps every inherited + // descriptor open, and adopting one marks it close-on-exec again. + #[cfg(target_os = "macos")] + crate::daemon::responsibility::disclaim_inherited(); + #[cfg(unix)] if let Some(inheritance) = crate::daemon::handoff::requested() { return run_adopting(inheritance);