From 811fab3b87fe5fc93bfefb7f80c3faed7888571a Mon Sep 17 00:00:00 2001 From: Argenis Date: Sun, 15 Mar 2026 19:16:36 -0400 Subject: [PATCH] fix(service): headless browser works in service mode (systemd/OpenRC) (#3645) When zeroclaw runs as a service, the process inherits a minimal environment without HOME, DISPLAY, or user namespaces. Headless browsers (Chromium/Firefox) need HOME for profile/cache dirs and fail with sandbox errors without user namespaces. - Detect service environment via INVOCATION_ID, JOURNAL_STREAM, or missing HOME on Linux - Auto-apply --no-sandbox and --disable-dev-shm-usage for Chrome in service mode - Set HOME fallback and CHROMIUM_FLAGS on agent-browser commands - systemd unit: add Environment=HOME=%h and PassEnvironment - OpenRC script: export HOME=/var/lib/zeroclaw with start_pre() to create the directory Closes #3584 --- src/service/mod.rs | 97 ++++++++++++++++++++++++++++++--- src/tools/browser.rs | 126 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 6 deletions(-) diff --git a/src/service/mod.rs b/src/service/mod.rs index aa7abe410..95913816e 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -442,8 +442,24 @@ fn install_linux_systemd(config: &Config) -> Result<()> { let exe = std::env::current_exe().context("Failed to resolve current executable")?; let unit = format!( - "[Unit]\nDescription=ZeroClaw daemon\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={} daemon\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n", - exe.display() + "[Unit]\n\ + Description=ZeroClaw daemon\n\ + After=network.target\n\ + \n\ + [Service]\n\ + Type=simple\n\ + ExecStart={exe} daemon\n\ + Restart=always\n\ + RestartSec=3\n\ + # Ensure HOME is set so headless browsers can create profile/cache dirs.\n\ + Environment=HOME=%h\n\ + # Allow inheriting DISPLAY and XDG_RUNTIME_DIR from the user session\n\ + # so graphical/headless browsers can function correctly.\n\ + PassEnvironment=DISPLAY XDG_RUNTIME_DIR\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n", + exe = exe.display() ); fs::write(&file, unit)?; @@ -826,8 +842,8 @@ fn generate_openrc_script(exe_path: &Path, config_dir: &Path) -> String { name="zeroclaw" description="ZeroClaw daemon" -command="{}" -command_args="--config-dir {} daemon" +command="{exe}" +command_args="--config-dir {config_dir} daemon" command_background="yes" command_user="zeroclaw:zeroclaw" pidfile="/run/${{RC_SVCNAME}}.pid" @@ -835,13 +851,21 @@ umask 027 output_log="/var/log/zeroclaw/access.log" error_log="/var/log/zeroclaw/error.log" +# Provide HOME so headless browsers can create profile/cache directories. +# Without this, Chromium/Firefox fail with sandbox or profile errors. +export HOME="/var/lib/zeroclaw" + depend() {{ need net after firewall }} + +start_pre() {{ + checkpath --directory --owner zeroclaw:zeroclaw --mode 0750 /var/lib/zeroclaw +}} "#, - exe_path.display(), - config_dir.display() + exe = exe_path.display(), + config_dir = config_dir.display(), ) } @@ -1196,6 +1220,67 @@ mod tests { assert!(script.contains("after firewall")); } + #[test] + fn generate_openrc_script_sets_home_for_browser() { + use std::path::PathBuf; + + let exe_path = PathBuf::from("/usr/local/bin/zeroclaw"); + let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw")); + + assert!( + script.contains("export HOME=\"/var/lib/zeroclaw\""), + "OpenRC script must set HOME for headless browser support" + ); + } + + #[test] + fn generate_openrc_script_creates_home_directory() { + use std::path::PathBuf; + + let exe_path = PathBuf::from("/usr/local/bin/zeroclaw"); + let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw")); + + assert!( + script.contains("start_pre()"), + "OpenRC script must have start_pre to create HOME dir" + ); + assert!( + script.contains("checkpath --directory --owner zeroclaw:zeroclaw"), + "start_pre must ensure /var/lib/zeroclaw exists with correct ownership" + ); + } + + #[test] + fn systemd_unit_contains_home_and_pass_environment() { + let unit = "[Unit]\n\ + Description=ZeroClaw daemon\n\ + After=network.target\n\ + \n\ + [Service]\n\ + Type=simple\n\ + ExecStart=/usr/local/bin/zeroclaw daemon\n\ + Restart=always\n\ + RestartSec=3\n\ + # Ensure HOME is set so headless browsers can create profile/cache dirs.\n\ + Environment=HOME=%h\n\ + # Allow inheriting DISPLAY and XDG_RUNTIME_DIR from the user session\n\ + # so graphical/headless browsers can function correctly.\n\ + PassEnvironment=DISPLAY XDG_RUNTIME_DIR\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n" + .to_string(); + + assert!( + unit.contains("Environment=HOME=%h"), + "systemd unit must set HOME for headless browser support" + ); + assert!( + unit.contains("PassEnvironment=DISPLAY XDG_RUNTIME_DIR"), + "systemd unit must pass through display/runtime env vars" + ); + } + #[test] fn warn_if_binary_in_home_detects_home_path() { use std::path::PathBuf; diff --git a/src/tools/browser.rs b/src/tools/browser.rs index 62a7cb6a0..1603176c1 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -440,6 +440,12 @@ impl BrowserTool { async fn run_command(&self, args: &[&str]) -> anyhow::Result { let mut cmd = Command::new("agent-browser"); + // When running as a service (systemd/OpenRC), the process may lack + // HOME which browsers need for profile directories. + if is_service_environment() { + ensure_browser_env(&mut cmd); + } + // Add session if configured if let Some(ref session) = self.session_name { cmd.arg("--session").arg(session); @@ -1461,6 +1467,14 @@ mod native_backend { args.push(Value::String("--disable-gpu".to_string())); } + // When running as a service (systemd/OpenRC), the browser sandbox + // fails because the process lacks a user namespace / session. + // --no-sandbox and --disable-dev-shm-usage are required in this context. + if is_service_environment() { + args.push(Value::String("--no-sandbox".to_string())); + args.push(Value::String("--disable-dev-shm-usage".to_string())); + } + if !args.is_empty() { chrome_options.insert("args".to_string(), Value::Array(args)); } @@ -2111,6 +2125,44 @@ fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { || v6.to_ipv4_mapped().is_some_and(is_non_global_v4) } +/// Detect whether the current process is running inside a service environment +/// (e.g. systemd, OpenRC, or launchd) where the browser sandbox and +/// environment setup may be restricted. +fn is_service_environment() -> bool { + if std::env::var_os("INVOCATION_ID").is_some() { + return true; + } + if std::env::var_os("JOURNAL_STREAM").is_some() { + return true; + } + #[cfg(target_os = "linux")] + if std::path::Path::new("/run/openrc").exists() && std::env::var_os("HOME").is_none() { + return true; + } + #[cfg(target_os = "linux")] + if std::env::var_os("HOME").is_none() { + return true; + } + false +} + +/// Ensure environment variables required by headless browsers are present +/// when running inside a service context. +fn ensure_browser_env(cmd: &mut Command) { + if std::env::var_os("HOME").is_none() { + cmd.env("HOME", "/tmp"); + } + let existing = std::env::var("CHROMIUM_FLAGS").unwrap_or_default(); + if !existing.contains("--no-sandbox") { + let new_flags = if existing.is_empty() { + "--no-sandbox --disable-dev-shm-usage".to_string() + } else { + format!("{existing} --no-sandbox --disable-dev-shm-usage") + }; + cmd.env("CHROMIUM_FLAGS", new_flags); + } +} + fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool { allowed.iter().any(|pattern| { if pattern == "*" { @@ -2492,4 +2544,78 @@ mod tests { state.reset_session().await; }); } + + #[test] + fn ensure_browser_env_sets_home_when_missing() { + let original_home = std::env::var_os("HOME"); + unsafe { std::env::remove_var("HOME") }; + + let mut cmd = Command::new("true"); + ensure_browser_env(&mut cmd); + // Function completes without panic — HOME and CHROMIUM_FLAGS set on cmd. + + if let Some(home) = original_home { + unsafe { std::env::set_var("HOME", home) }; + } + } + + #[test] + fn ensure_browser_env_sets_chromium_flags() { + let original = std::env::var_os("CHROMIUM_FLAGS"); + unsafe { std::env::remove_var("CHROMIUM_FLAGS") }; + + let mut cmd = Command::new("true"); + ensure_browser_env(&mut cmd); + + if let Some(val) = original { + unsafe { std::env::set_var("CHROMIUM_FLAGS", val) }; + } + } + + #[test] + fn is_service_environment_detects_invocation_id() { + let original = std::env::var_os("INVOCATION_ID"); + unsafe { std::env::set_var("INVOCATION_ID", "test-unit-id") }; + + assert!(is_service_environment()); + + if let Some(val) = original { + unsafe { std::env::set_var("INVOCATION_ID", val) }; + } else { + unsafe { std::env::remove_var("INVOCATION_ID") }; + } + } + + #[test] + fn is_service_environment_detects_journal_stream() { + let original = std::env::var_os("JOURNAL_STREAM"); + unsafe { std::env::set_var("JOURNAL_STREAM", "8:12345") }; + + assert!(is_service_environment()); + + if let Some(val) = original { + unsafe { std::env::set_var("JOURNAL_STREAM", val) }; + } else { + unsafe { std::env::remove_var("JOURNAL_STREAM") }; + } + } + + #[test] + fn is_service_environment_false_in_normal_context() { + let inv = std::env::var_os("INVOCATION_ID"); + let journal = std::env::var_os("JOURNAL_STREAM"); + unsafe { std::env::remove_var("INVOCATION_ID") }; + unsafe { std::env::remove_var("JOURNAL_STREAM") }; + + if std::env::var_os("HOME").is_some() { + assert!(!is_service_environment()); + } + + if let Some(val) = inv { + unsafe { std::env::set_var("INVOCATION_ID", val) }; + } + if let Some(val) = journal { + unsafe { std::env::set_var("JOURNAL_STREAM", val) }; + } + } }