diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 46f7bba71..4a0d67ea4 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5820,6 +5820,7 @@ dependencies = [ name = "maple-agent-app" version = "0.0.7" dependencies = [ + "ashpd", "axum", "base64 0.22.1", "chrono", @@ -5827,6 +5828,7 @@ dependencies = [ "cpal", "dirs 6.0.0", "env_logger", + "futures-lite", "gpui", "gpui_platform", "hostname", diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 55031a9ac..d887e7bea 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -133,6 +133,12 @@ Goose, with an independent dependency graph. (detected built-ins and custom MCP servers), Usage (the plan meter from the billing API), and About. - Dark and light themes; the default follows the system. + On Linux, the protected GUI starts a portal-only child of the same executable + (`--internal-theme-watch`). The child receives only desktop-bus environment, + never opens Maple state, disables core dumps, and exits when its parent pipe + closes. Only the child remains debugger-accessible for portal identification. + Setup has a two-second background deadline; unavailable portals do not block + launch. Explicit Dark/Light settings override system updates. - Billing status from the Maple billing API. - Desktop notifications when a task finishes or asks a question while the window is not focused. @@ -593,6 +599,18 @@ cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all -- --check ``` +On a Linux desktop with a live Settings portal, run the opt-in helper lifecycle +regression from the same component shell: + +```sh +cargo test --locked -p maple-agent-app --test system_theme_helper -- --ignored +``` + +It launches the real executable in helper mode and verifies theme output, +parent-pipe shutdown, and absence of application-state writes. It does not +change the desktop theme. Parser, environment-scrubbing and palette precedence +tests run in the ordinary app test suite. + Root `.github/workflows/agent-ci.yml` selects this component when Agent or its shared Rust dependencies change. It builds and tests Linux, macOS, and Windows; Linux additionally runs the feature-matrix lint/headless checks and diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index dd4dbc109..dc5ff35a1 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -27,6 +27,8 @@ desktop = [ "dep:wayland-client", "dep:tray-icon", "dep:zbus", + "dep:ashpd", + "dep:futures-lite", "dep:objc", "dep:raw-window-handle", ] @@ -90,6 +92,9 @@ tower-http = { version = "0.6", features = ["cors"], optional = true } [target.'cfg(target_os = "linux")'.dependencies] wayland-client = { version = "0.31", optional = true } zbus = { version = "=5.19.0", optional = true } +# Used only in the credential-free desktop theme helper, not the GUI process. +ashpd = { version = "=0.13.13", default-features = false, features = ["async-io", "settings"], optional = true } +futures-lite = { version = "2", optional = true } [target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies] tray-icon = { version = "=0.26.1", default-features = false, features = ["ksni"], optional = true } diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 0bcbe3e9e..25a2c22a7 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -3014,6 +3014,8 @@ pub fn run() { cx, ); ui::theme::apply_preference(ui::theme::Preference::parse(&startup_settings.theme), cx); + #[cfg(target_os = "linux")] + crate::platform::portal::start(&backend, cx); ui::typography::apply( ui::typography::ChatFontFamily::parse(&startup_settings.chat_font_family), startup_settings.chat_font_size, diff --git a/apps/maple-agent/app/src/host/hardening.rs b/apps/maple-agent/app/src/host/hardening.rs index e48c47064..e5a36a2e8 100644 --- a/apps/maple-agent/app/src/host/hardening.rs +++ b/apps/maple-agent/app/src/host/hardening.rs @@ -12,16 +12,7 @@ pub(crate) fn apply() -> Result<(), String> { return Ok(()); } #[cfg(unix)] - { - let limit = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: a valid, initialized rlimit pointer; process-local policy. - if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &limit) } != 0 { - return Err("Could not disable Maple crash dumps.".into()); - } - } + disable_core_dumps()?; #[cfg(target_os = "linux")] { // SAFETY: PR_SET_DUMPABLE takes the integer zero, no pointer arguments. @@ -64,6 +55,19 @@ pub(crate) fn apply() -> Result<(), String> { Ok(()) } +#[cfg(unix)] +pub(crate) fn disable_core_dumps() -> Result<(), String> { + let limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: a valid, initialized rlimit pointer; process-local policy. + if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &limit) } != 0 { + return Err("Could not disable Maple crash dumps.".into()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 9ede4ee69..8653045d3 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -69,6 +69,10 @@ struct Cli { conflicts_with = "build_info" )] internal_store_integrity_check: Option, + /// Internal Linux desktop-portal watcher; never opens application state. + #[cfg(all(feature = "desktop", target_os = "linux"))] + #[arg(long, hide = true, conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args"])] + internal_theme_watch: bool, #[command(subcommand)] mode: Option, /// Arguments a desktop launcher may append (file paths, `%u` expansions). @@ -234,6 +238,16 @@ fn main() { ); return; } + #[cfg(all(feature = "desktop", target_os = "linux"))] + if cli.internal_theme_watch { + // The helper is a separate, credential-free process. It must remain + // identifiable to the portal, but never reaches normal app startup. + if platform::portal::run_helper().is_err() { + eprintln!("Desktop theme watcher unavailable."); + std::process::exit(1); + } + return; + } if let Err(error) = host::hardening::apply() { eprintln!("{error}"); std::process::exit(1); @@ -246,8 +260,8 @@ fn main() { } return; } - // SAFETY: argument parsing starts no threads. No other thread exists - // yet, so mutating the process environment here cannot race a reader. + // SAFETY: argument parsing and hardening start no threads. No other + // thread exists yet, so environment mutation cannot race a reader. unsafe { maple_agent::prepare_process_environment() }; #[cfg(feature = "desktop")] let _ = PROCESS_START.set(std::time::Instant::now()); diff --git a/apps/maple-agent/app/src/platform.rs b/apps/maple-agent/app/src/platform.rs index 0ded385e3..86e1cb661 100644 --- a/apps/maple-agent/app/src/platform.rs +++ b/apps/maple-agent/app/src/platform.rs @@ -2,6 +2,9 @@ use std::process::Command; +#[cfg(target_os = "linux")] +pub(crate) mod portal; + /// Open a folder in the platform file manager. Runs the launcher without /// waiting for it; call from a blocking task, not the UI thread. pub fn reveal_folder(path: &str) -> Result<(), String> { diff --git a/apps/maple-agent/app/src/platform/portal.rs b/apps/maple-agent/app/src/platform/portal.rs new file mode 100644 index 000000000..f3cb80a45 --- /dev/null +++ b/apps/maple-agent/app/src/platform/portal.rs @@ -0,0 +1,353 @@ +//! System appearance through a credential-free instance of this executable. +//! +//! The GUI remains non-dumpable from startup. Only this helper stays visible +//! through /proc so xdg-desktop-portal can identify its caller. The helper never +//! opens Maple state; its only output is a bounded stream of theme names. + +use std::{io, io::Write as _, process::Stdio, time::Duration}; + +use futures_lite::StreamExt as _; +use tokio::io::AsyncReadExt as _; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_FRAME: usize = 8; +const DESKTOP_ENVIRONMENT: &[&str] = &["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Scheme { + Dark, + Light, + Default, +} + +impl Scheme { + fn frame(self) -> &'static [u8] { + match self { + Self::Dark => b"dark\n", + Self::Light => b"light\n", + Self::Default => b"default\n", + } + } + + fn parse(frame: &[u8]) -> io::Result { + match frame { + b"dark\n" => Ok(Self::Dark), + b"light\n" => Ok(Self::Light), + b"default\n" => Ok(Self::Default), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid theme frame", + )), + } + } + + fn appearance(self) -> gpui::WindowAppearance { + match self { + Self::Dark => gpui::WindowAppearance::Dark, + Self::Light | Self::Default => gpui::WindowAppearance::Light, + } + } +} + +impl From for Scheme { + fn from(value: ashpd::desktop::settings::ColorScheme) -> Self { + use ashpd::desktop::settings::ColorScheme; + match value { + ColorScheme::PreferDark => Self::Dark, + ColorScheme::PreferLight => Self::Light, + ColorScheme::NoPreference => Self::Default, + } + } +} + +/// This is a terminal startup branch: no logging, store, backend, or GUI. +pub(crate) fn run_helper() -> io::Result<()> { + crate::host::hardening::disable_core_dumps().map_err(io::Error::other)?; + // Parent lifetime is represented by its private stdin pipe, not a PID + // lookup. EOF also covers GUI crashes and prevents orphaned subscribers. + std::thread::Builder::new() + .name("theme-parent".into()) + .spawn(|| { + use std::io::Read as _; + let _ = io::stdin().read(&mut [0_u8]); + std::process::exit(0); + })?; + futures_lite::future::block_on(async { + // The GUI owns the startup deadline and kills an unresponsive helper. + let settings = ashpd::desktop::settings::Settings::new() + .await + .map_err(|_| io::Error::other("Desktop portal unavailable"))?; + // Subscribe before reading so a concurrent desktop switch cannot + // fall into a gap between the initial read and the subscription. + let mut changes = settings + .receive_color_scheme_changed() + .await + .map_err(|_| io::Error::other("Desktop portal subscription unavailable"))?; + let initial = settings + .color_scheme() + .await + .map_err(|_| io::Error::other("Desktop portal appearance unavailable"))?; + let mut output = io::stdout().lock(); + let mut previous = Scheme::from(initial); + output + .write_all(previous.frame()) + .and_then(|()| output.flush())?; + while let Some(value) = changes.next().await { + let scheme = Scheme::from(value); + if scheme != previous { + output + .write_all(scheme.frame()) + .and_then(|()| output.flush())?; + previous = scheme; + } + } + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Desktop portal stream closed", + )) + }) +} + +fn helper_command() -> io::Result { + // Resolve the bundled loader/payload before clearing package environment. + let mut command = crate::self_launch::child_command().map_err(io::Error::other)?; + configure_helper(&mut command, |name| std::env::var_os(name)); + Ok(command) +} + +fn configure_helper( + command: &mut std::process::Command, + mut environment: impl FnMut(&str) -> Option, +) { + command + .env_clear() + .current_dir("/") + .arg("--internal-theme-watch"); + for name in DESKTOP_ENVIRONMENT { + if let Some(value) = environment(name) { + command.env(name, value); + } + } + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // The helper stays dumpable, so same-user processes can open its + // /proc//fd entries. Native GUI libraries may leak descriptors without + // CLOEXEC; close everything past stdio before exec. Kernels without + // close_range fail the spawn and the GUI keeps its native appearance. + // SAFETY: the closure makes one async-signal-safe syscall and allocates nothing. + unsafe { + use std::os::unix::process::CommandExt as _; + command.pre_exec(|| { + if libc::syscall(libc::SYS_close_range, 3_u32, u32::MAX, 0_u32) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } +} + +/// Start once per desktop process. No portal calls occur on the UI thread. +pub(crate) fn start(backend: &crate::backend::AgentBackend, cx: &mut gpui::App) { + let (sender, mut receiver) = tokio::sync::watch::channel(None); + let mut task = Some(backend.spawn(async move { + // Package resolution can read filesystem metadata. Keep that off the + // UI thread along with subprocess startup and pipe reads. + let result = async { + let command = helper_command()?; + monitor(command, sender).await + } + .await; + if result.is_err() { + log::warn!("Desktop theme watcher stopped; retaining the last system appearance"); + } + })); + cx.on_app_quit(move |_| { + let task = task.take(); + async move { + if let Some(task) = task { + task.abort(); + let _ = task.await; + } + } + }) + .detach(); + cx.spawn(async move |cx| { + while receiver.changed().await.is_ok() { + let scheme = *receiver.borrow_and_update(); + if let Some(scheme) = scheme { + cx.update(|cx| { + crate::ui::theme::set_system_appearance(scheme.appearance()); + if crate::ui::theme::preference() == crate::ui::theme::Preference::System { + cx.refresh_windows(); + } + }); + } + } + }) + .detach(); +} + +async fn monitor( + command: std::process::Command, + sender: tokio::sync::watch::Sender>, +) -> io::Result<()> { + let mut child = tokio::process::Command::from(command) + .kill_on_drop(true) + .spawn()?; + let lifetime = child + .stdin + .take() + .ok_or_else(|| io::Error::other("Missing lifetime pipe"))?; + let mut output = child + .stdout + .take() + .ok_or_else(|| io::Error::other("Missing theme pipe"))?; + let result = async { + let initial = tokio::select! { + _ = sender.closed() => return Ok(()), + value = tokio::time::timeout(STARTUP_TIMEOUT, read_frame(&mut output)) => { + value.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "Theme watcher timed out"))?? + } + }; + sender.send_replace(Some(initial)); + loop { + tokio::select! { + _ = sender.closed() => return Ok(()), + value = read_frame(&mut output) => { + let scheme = value?; + sender.send_if_modified(|current| { + if *current == Some(scheme) { return false; } + *current = Some(scheme); + true + }); + } + } + } + }.await; + drop(lifetime); + let _ = child.kill().await; + let _ = child.wait().await; + result +} + +async fn read_frame(input: &mut (impl tokio::io::AsyncRead + Unpin)) -> io::Result { + let mut frame = [0; MAX_FRAME]; + for index in 0..MAX_FRAME { + frame[index] = input.read_u8().await?; + if frame[index] == b'\n' { + return Scheme::parse(&frame[..=index]); + } + } + Err(io::Error::new( + io::ErrorKind::InvalidData, + "Oversized theme frame", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn frames_are_bounded_and_strict() { + for (mut frame, expected) in [ + (b"dark\n".as_slice(), Scheme::Dark), + (b"light\n", Scheme::Light), + (b"default\n", Scheme::Default), + ] { + assert_eq!(read_frame(&mut frame).await.unwrap(), expected); + } + for mut frame in [ + b"DARK\n".as_slice(), + b"dark\r\n", + b"\n", + b"dark", + b"anything-too-long\n", + ] { + assert!(read_frame(&mut frame).await.is_err()); + } + } + + #[tokio::test] + async fn reads_one_frame_without_consuming_the_next() { + let mut input = b"dark\nlight\ndefault\n".as_slice(); + assert_eq!(read_frame(&mut input).await.unwrap(), Scheme::Dark); + assert_eq!(read_frame(&mut input).await.unwrap(), Scheme::Light); + assert_eq!(read_frame(&mut input).await.unwrap(), Scheme::Default); + } + + fn idle_child() -> std::process::Command { + let mut command = std::process::Command::new("sleep"); + command + .arg("60") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + command + } + + #[tokio::test] + async fn closed_ui_receiver_stops_and_reaps_an_idle_child() { + let (sender, receiver) = tokio::sync::watch::channel(None); + drop(receiver); + tokio::time::timeout(Duration::from_secs(1), monitor(idle_child(), sender)) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn helper_startup_has_a_deadline() { + let (sender, _receiver) = tokio::sync::watch::channel(None); + let error = tokio::time::timeout(Duration::from_secs(5), monitor(idle_child(), sender)) + .await + .unwrap() + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + } + + #[test] + fn helper_receives_only_desktop_bus_environment() { + let mut command = std::process::Command::new("maple-agent"); + command.env("MAPLE_THEME_TEST_SECRET", "must-not-reach-helper"); + configure_helper(&mut command, |key| Some(format!("fixture-{key}").into())); + let environment: Vec<_> = command + .get_envs() + .map(|(key, value)| (key.to_str().unwrap(), value.unwrap().to_str().unwrap())) + .collect(); + assert_eq!( + environment, + vec![ + ( + "DBUS_SESSION_BUS_ADDRESS", + "fixture-DBUS_SESSION_BUS_ADDRESS" + ), + ("XDG_RUNTIME_DIR", "fixture-XDG_RUNTIME_DIR") + ] + ); + assert_eq!(command.get_current_dir(), Some(std::path::Path::new("/"))); + assert_eq!( + command.get_args().collect::>(), + ["--internal-theme-watch"] + ); + } + + #[test] + fn helper_does_not_inherit_leaked_descriptors() { + let file = std::fs::File::open("/dev/null").unwrap(); + // dup() leaves the copy without CLOEXEC, like a careless native library. + let leaked = unsafe { libc::dup(std::os::fd::AsRawFd::as_raw_fd(&file)) }; + assert!(leaked > 2); + let mut command = std::process::Command::new("/bin/sh"); + command.arg("-c").arg(format!( + "if [ -e /proc/self/fd/{leaked} ]; then echo leaked; else echo closed; fi" + )); + configure_helper(&mut command, |_| None); + let output = command.output().unwrap(); + unsafe { libc::close(leaked) }; + assert!(output.status.success()); + assert_eq!(output.stdout, b"closed\n"); + } +} diff --git a/apps/maple-agent/app/src/self_launch.rs b/apps/maple-agent/app/src/self_launch.rs index b916c0fe0..708ce937d 100644 --- a/apps/maple-agent/app/src/self_launch.rs +++ b/apps/maple-agent/app/src/self_launch.rs @@ -1,5 +1,7 @@ //! App entry points share package validation with bounded core subprocesses. +#[cfg(all(target_os = "linux", feature = "desktop"))] +pub use maple_agent::self_launch::child_command; pub use maple_agent::self_launch::command; #[cfg(all(target_os = "linux", feature = "desktop"))] pub use maple_agent::self_launch::executable; diff --git a/apps/maple-agent/app/src/ui/theme.rs b/apps/maple-agent/app/src/ui/theme.rs index e384e852a..0551fef9f 100644 --- a/apps/maple-agent/app/src/ui/theme.rs +++ b/apps/maple-agent/app/src/ui/theme.rs @@ -51,6 +51,29 @@ impl Preference { static PREFERENCE: AtomicU8 = AtomicU8::new(0); static LIGHT_ACTIVE: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "linux")] +static SYSTEM_APPEARANCE: AtomicU8 = AtomicU8::new(0); + +/// Linux's protected GUI cannot query the portal itself. The credential-free +/// helper supplies this value; native appearance remains the fallback. +#[cfg(target_os = "linux")] +pub fn set_system_appearance(appearance: gpui::WindowAppearance) { + SYSTEM_APPEARANCE.store( + if appearance_is_light(appearance) { + 2 + } else { + 1 + }, + Ordering::Relaxed, + ); +} + +fn appearance_is_light(appearance: gpui::WindowAppearance) -> bool { + matches!( + appearance, + gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight + ) +} pub fn set_preference(preference: Preference) { PREFERENCE.store( @@ -88,17 +111,50 @@ pub fn preference() -> Preference { /// whether the active palette changed, in which case every view must /// render again. pub fn resolve(appearance: gpui::WindowAppearance) -> bool { - let light = match preference() { - Preference::Dark => false, - Preference::Light => true, - Preference::System => matches!( - appearance, - gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight - ), + #[cfg(target_os = "linux")] + let system = match SYSTEM_APPEARANCE.load(Ordering::Relaxed) { + 1 => Some(false), + 2 => Some(true), + _ => None, }; + #[cfg(not(target_os = "linux"))] + let system = None; + let light = resolved_light(preference(), appearance, system); LIGHT_ACTIVE.swap(light, Ordering::Relaxed) != light } +fn resolved_light( + preference: Preference, + appearance: gpui::WindowAppearance, + system: Option, +) -> bool { + match preference { + Preference::Dark => false, + Preference::Light => true, + Preference::System => system.unwrap_or_else(|| appearance_is_light(appearance)), + } +} + +#[cfg(test)] +mod system_appearance_tests { + use super::*; + use gpui::WindowAppearance::{Dark, Light}; + + #[test] + fn helper_overrides_stale_native_appearance_only_in_system_mode() { + assert!(!resolved_light(Preference::System, Light, Some(false))); + assert!(resolved_light(Preference::System, Dark, Some(true))); + assert!(!resolved_light(Preference::Dark, Light, Some(true))); + assert!(resolved_light(Preference::Light, Dark, Some(false))); + } + + #[test] + fn missing_helper_preserves_native_fallback() { + assert!(resolved_light(Preference::System, Light, None)); + assert!(!resolved_light(Preference::System, Dark, None)); + } +} + pub fn is_light() -> bool { LIGHT_ACTIVE.load(Ordering::Relaxed) } diff --git a/apps/maple-agent/app/tests/system_theme_helper.rs b/apps/maple-agent/app/tests/system_theme_helper.rs new file mode 100644 index 000000000..e3a00049a --- /dev/null +++ b/apps/maple-agent/app/tests/system_theme_helper.rs @@ -0,0 +1,133 @@ +//! Exact-executable checks. No account, backend, or GUI is needed. +#![cfg(all(target_os = "linux", feature = "desktop"))] + +use std::{process::Stdio, time::Duration}; + +use tokio::{io::AsyncReadExt as _, process::Command, time::timeout}; + +const BINARY: &str = env!("CARGO_BIN_EXE_maple-agent"); +const DEADLINE: Duration = Duration::from_secs(5); + +fn helper() -> Command { + let mut command = Command::new(BINARY); + command + .env_clear() + .current_dir("/") + .arg("--internal-theme-watch") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + command +} + +#[tokio::test] +async fn helper_failure_never_opens_application_state() { + let state = tempfile::tempdir().unwrap(); + let mut child = helper() + .env( + "DBUS_SESSION_BUS_ADDRESS", + "unix:path=/nonexistent-maple-theme-test-bus", + ) + .env("XDG_DATA_HOME", state.path().join("data")) + .env("XDG_CONFIG_HOME", state.path().join("config")) + .spawn() + .unwrap(); + let _lifetime = child.stdin.take().unwrap(); + let output = timeout(DEADLINE, child.wait_with_output()) + .await + .unwrap() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert_eq!(output.stderr, b"Desktop theme watcher unavailable.\n"); + assert_eq!(std::fs::read_dir(state.path()).unwrap().count(), 0); +} + +#[tokio::test] +async fn internal_mode_is_hidden_and_cannot_start_other_modes() { + let output = timeout( + DEADLINE, + Command::new(BINARY).env_clear().arg("--help").output(), + ) + .await + .unwrap() + .unwrap(); + assert!(output.status.success()); + assert!( + !String::from_utf8(output.stdout) + .unwrap() + .contains("internal-theme-watch") + ); + for arguments in [ + vec!["--build-info"], + vec!["host", "serve"], + vec!["--internal-store-integrity-check", "/nonexistent-store"], + vec!["unexpected-desktop-argument"], + ] { + let output = timeout(DEADLINE, helper().args(arguments).output()) + .await + .unwrap() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + } +} + +#[tokio::test] +#[ignore = "requires a live desktop Settings portal"] +async fn live_portal_helper_exits_when_parent_pipe_closes() { + let state = tempfile::tempdir().unwrap(); + let mut command = helper(); + for key in ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + // Canary roots prove this startup branch never initializes application state. + command + .env("XDG_DATA_HOME", state.path().join("data")) + .env("XDG_CONFIG_HOME", state.path().join("config")); + let mut child = command.spawn().unwrap(); + let lifetime = child.stdin.take().unwrap(); + let mut output = child.stdout.take().unwrap(); + let mut frame = Vec::new(); + timeout(DEADLINE, async { + for _ in 0..8 { + let byte = output.read_u8().await.unwrap(); + frame.push(byte); + if byte == b'\n' { + break; + } + } + }) + .await + .unwrap(); + assert!(matches!( + frame.as_slice(), + b"dark\n" | b"light\n" | b"default\n" + )); + // It must be a watcher, not a one-shot read. + assert!(child.try_wait().unwrap().is_none()); + let pid = child.id().unwrap(); + // Portal identity needs /proc access, but ordinary core dumps remain off. + assert!(std::fs::read_link(format!("/proc/{pid}/root")).is_ok()); + let limits = std::fs::read_to_string(format!("/proc/{pid}/limits")).unwrap(); + let core = limits + .lines() + .find(|line| line.starts_with("Max core file size")) + .unwrap(); + assert_eq!( + core.split_whitespace().skip(4).take(2).collect::>(), + ["0", "0"] + ); + drop(lifetime); + assert!( + timeout(DEADLINE, child.wait()) + .await + .unwrap() + .unwrap() + .success() + ); + assert_eq!(std::fs::read_dir(state.path()).unwrap().count(), 0); +}