Skip to main content

zeroclaw_runtime/service/
mod.rs

1use anyhow::{Context, Result, bail};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::str::FromStr;
6use zeroclaw_config::schema::Config;
7
8const SERVICE_LABEL: &str = "com.zeroclaw.daemon";
9const WINDOWS_TASK_NAME: &str = "ZeroClaw Daemon";
10
11/// Supported init systems for service management
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum InitSystem {
14    /// Auto-detect based on system indicators
15    #[default]
16    Auto,
17    /// systemd (via systemctl --user)
18    Systemd,
19    /// OpenRC (via rc-service)
20    Openrc,
21}
22
23impl FromStr for InitSystem {
24    type Err = anyhow::Error;
25
26    fn from_str(s: &str) -> Result<Self> {
27        match s.to_lowercase().as_str() {
28            "auto" => Ok(Self::Auto),
29            "systemd" => Ok(Self::Systemd),
30            "openrc" => Ok(Self::Openrc),
31            other => bail!(
32                "Unknown init system: '{}'. Supported: auto, systemd, openrc",
33                other
34            ),
35        }
36    }
37}
38
39impl InitSystem {
40    /// Resolve auto-detection to a concrete init system
41    ///
42    /// Detection order (deny-by-default):
43    /// 1. `/run/systemd/system` exists → Systemd
44    /// 2. `/run/openrc` exists AND OpenRC binary present → OpenRC
45    /// 3. else → Error (unknown init system)
46    #[cfg(target_os = "linux")]
47    pub fn resolve(self) -> Result<Self> {
48        match self {
49            Self::Auto => detect_init_system(),
50            concrete => Ok(concrete),
51        }
52    }
53
54    #[cfg(not(target_os = "linux"))]
55    pub fn resolve(self) -> Result<Self> {
56        match self {
57            Self::Auto => Ok(Self::Systemd),
58            concrete => Ok(concrete),
59        }
60    }
61}
62
63/// Detect the active init system on Linux
64///
65/// Checks for systemd and OpenRC in order, returning the first match.
66/// Returns an error if neither is detected.
67#[cfg(target_os = "linux")]
68fn detect_init_system() -> Result<InitSystem> {
69    // Check for systemd first (most common on modern Linux)
70    if Path::new("/run/systemd/system").exists() {
71        return Ok(InitSystem::Systemd);
72    }
73
74    // Check for OpenRC: requires /run/openrc AND openrc binary
75    if Path::new("/run/openrc").exists() {
76        // Check for OpenRC binaries: /sbin/openrc-run or rc-service in PATH
77        if Path::new("/sbin/openrc-run").exists() || which::which("rc-service").is_ok() {
78            return Ok(InitSystem::Openrc);
79        }
80    }
81
82    bail!(
83        "Could not detect init system. Supported: systemd, OpenRC. \
84         Use --service-init to specify manually."
85    );
86}
87
88fn windows_task_name() -> &'static str {
89    WINDOWS_TASK_NAME
90}
91
92/// Returns whether the ZeroClaw daemon service is currently running.
93pub fn is_running() -> bool {
94    if cfg!(target_os = "macos") {
95        run_capture(Command::new("launchctl").arg("list"))
96            .map(|out| out.lines().any(|l| l.contains(SERVICE_LABEL)))
97            .unwrap_or(false)
98    } else if cfg!(target_os = "linux") {
99        is_running_linux()
100    } else if cfg!(target_os = "windows") {
101        run_capture(Command::new("schtasks").args([
102            "/Query",
103            "/TN",
104            WINDOWS_TASK_NAME,
105            "/FO",
106            "LIST",
107        ]))
108        .map(|out| out.contains("Running"))
109        .unwrap_or(false)
110    } else {
111        false
112    }
113}
114
115fn is_running_linux() -> bool {
116    // Try systemd first, then OpenRC — mirrors detect_init_system() order
117    if run_capture(Command::new("systemctl").args(["--user", "is-active", "zeroclaw.service"]))
118        .map(|out| out.trim() == "active")
119        .unwrap_or(false)
120    {
121        return true;
122    }
123    run_capture(Command::new("rc-service").args(["zeroclaw", "status"]))
124        .map(|out| out.contains("started"))
125        .unwrap_or(false)
126}
127
128pub fn install(config: &Config, init_system: InitSystem) -> Result<()> {
129    if cfg!(target_os = "macos") {
130        install_macos(config)
131    } else if cfg!(target_os = "linux") {
132        let resolved = init_system.resolve()?;
133        install_linux(config, resolved)
134    } else if cfg!(target_os = "windows") {
135        install_windows(config)
136    } else {
137        anyhow::bail!("Service management is supported on macOS and Linux only");
138    }
139}
140
141pub fn start(config: &Config, init_system: InitSystem) -> Result<()> {
142    if cfg!(target_os = "macos") {
143        // Ensure the Homebrew var directory exists before launchd tries to use it.
144        // The plist may reference this path for WorkingDirectory and log files.
145        let exe = std::env::current_exe().ok();
146        if let Some(ref exe_path) = exe
147            && let Some(var_dir) = homebrew_var_dir_from_exe(exe_path)
148        {
149            let _ = fs::create_dir_all(&var_dir);
150        }
151        let plist = macos_service_file()?;
152        run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?;
153        run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL))?;
154        println!("✅ Service started");
155        Ok(())
156    } else if cfg!(target_os = "linux") {
157        let resolved = init_system.resolve()?;
158        start_linux(resolved)
159    } else if cfg!(target_os = "windows") {
160        let _ = config;
161        run_checked(Command::new("schtasks").args(["/Run", "/TN", windows_task_name()]))?;
162        println!("✅ Service started");
163        Ok(())
164    } else {
165        let _ = config;
166        anyhow::bail!("Service management is supported on macOS and Linux only")
167    }
168}
169
170fn start_linux(init_system: InitSystem) -> Result<()> {
171    match init_system {
172        InitSystem::Systemd => {
173            run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?;
174            run_checked(Command::new("systemctl").args(["--user", "start", "zeroclaw.service"]))?;
175        }
176        InitSystem::Openrc => {
177            run_checked(Command::new("rc-service").args(["zeroclaw", "start"]))?;
178        }
179        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
180    }
181    println!("✅ Service started");
182    Ok(())
183}
184
185pub fn stop(config: &Config, init_system: InitSystem) -> Result<()> {
186    if cfg!(target_os = "macos") {
187        let plist = macos_service_file()?;
188        let _ = run_checked(Command::new("launchctl").arg("stop").arg(SERVICE_LABEL));
189        let _ = run_checked(
190            Command::new("launchctl")
191                .arg("unload")
192                .arg("-w")
193                .arg(&plist),
194        );
195        println!("✅ Service stopped");
196        Ok(())
197    } else if cfg!(target_os = "linux") {
198        let resolved = init_system.resolve()?;
199        stop_linux(resolved)
200    } else if cfg!(target_os = "windows") {
201        let _ = config;
202        let task_name = windows_task_name();
203        let _ = run_checked(Command::new("schtasks").args(["/End", "/TN", task_name]));
204        println!("✅ Service stopped");
205        Ok(())
206    } else {
207        let _ = config;
208        anyhow::bail!("Service management is supported on macOS and Linux only")
209    }
210}
211
212fn stop_linux(init_system: InitSystem) -> Result<()> {
213    match init_system {
214        InitSystem::Systemd => {
215            let _ =
216                run_checked(Command::new("systemctl").args(["--user", "stop", "zeroclaw.service"]));
217        }
218        InitSystem::Openrc => {
219            let _ = run_checked(Command::new("rc-service").args(["zeroclaw", "stop"]));
220        }
221        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
222    }
223    println!("✅ Service stopped");
224    Ok(())
225}
226
227pub fn restart(config: &Config, init_system: InitSystem) -> Result<()> {
228    if cfg!(target_os = "macos") {
229        stop(config, init_system)?;
230        start(config, init_system)?;
231        println!("✅ Service restarted");
232        return Ok(());
233    }
234
235    if cfg!(target_os = "linux") {
236        let resolved = init_system.resolve()?;
237        return restart_linux(resolved);
238    }
239
240    if cfg!(target_os = "windows") {
241        stop(config, init_system)?;
242        start(config, init_system)?;
243        println!("✅ Service restarted");
244        return Ok(());
245    }
246
247    anyhow::bail!("Service management is supported on macOS and Linux only")
248}
249
250fn restart_linux(init_system: InitSystem) -> Result<()> {
251    match init_system {
252        InitSystem::Systemd => {
253            run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?;
254            run_checked(Command::new("systemctl").args(["--user", "restart", "zeroclaw.service"]))?;
255        }
256        InitSystem::Openrc => {
257            run_checked(Command::new("rc-service").args(["zeroclaw", "restart"]))?;
258        }
259        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
260    }
261    println!("✅ Service restarted");
262    Ok(())
263}
264
265pub fn status(config: &Config, init_system: InitSystem) -> Result<()> {
266    if cfg!(target_os = "macos") {
267        let out = run_capture(Command::new("launchctl").arg("list"))?;
268        let running = out.lines().any(|line| line.contains(SERVICE_LABEL));
269        println!(
270            "Service: {}",
271            if running {
272                "✅ running/loaded"
273            } else {
274                "❌ not loaded"
275            }
276        );
277        println!("Unit: {}", macos_service_file()?.display().to_string());
278        return Ok(());
279    }
280
281    if cfg!(target_os = "linux") {
282        let resolved = init_system.resolve()?;
283        return status_linux(config, resolved);
284    }
285
286    if cfg!(target_os = "windows") {
287        let _ = config;
288        let task_name = windows_task_name();
289        let out =
290            run_capture(Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"]));
291        match out {
292            Ok(text) => {
293                let running = text.contains("Running");
294                println!(
295                    "Service: {}",
296                    if running {
297                        "✅ running"
298                    } else {
299                        "❌ not running"
300                    }
301                );
302                println!("Task: {}", task_name);
303            }
304            Err(_) => {
305                println!("Service: ❌ not installed");
306            }
307        }
308        return Ok(());
309    }
310
311    anyhow::bail!("Service management is supported on macOS and Linux only")
312}
313
314fn status_linux(config: &Config, init_system: InitSystem) -> Result<()> {
315    match init_system {
316        InitSystem::Systemd => {
317            let out = run_capture(Command::new("systemctl").args([
318                "--user",
319                "is-active",
320                "zeroclaw.service",
321            ]))
322            .unwrap_or_else(|_| "unknown".into());
323            println!("Service state: {}", out.trim());
324            println!(
325                "Unit: {}",
326                linux_service_file(config)?.display().to_string()
327            );
328        }
329        InitSystem::Openrc => {
330            let out = run_capture(Command::new("rc-service").args(["zeroclaw", "status"]))
331                .unwrap_or_else(|_| "unknown".into());
332            println!("Service state: {}", out.trim());
333            println!("Unit: /etc/init.d/zeroclaw");
334        }
335        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
336    }
337    Ok(())
338}
339
340pub fn logs(config: &Config, init_system: InitSystem, lines: usize, follow: bool) -> Result<()> {
341    if cfg!(target_os = "macos") {
342        return logs_macos(config, lines, follow);
343    }
344    if cfg!(target_os = "linux") {
345        let resolved = init_system.resolve()?;
346        return logs_linux(config, resolved, lines, follow);
347    }
348    if cfg!(target_os = "windows") {
349        return logs_windows(config, lines, follow);
350    }
351    anyhow::bail!("Service log viewing is supported on macOS, Linux, and Windows only")
352}
353
354fn logs_macos(config: &Config, lines: usize, follow: bool) -> Result<()> {
355    // Try the launchd log files first (StandardOutPath / StandardErrorPath from the plist).
356    // These are the most reliable source since they capture all daemon output.
357    let exe = std::env::current_exe().ok();
358    let homebrew_var_dir = exe.as_ref().and_then(|e| homebrew_var_dir_from_exe(e));
359    let logs_dir = if let Some(ref var_dir) = homebrew_var_dir {
360        var_dir.join("logs")
361    } else {
362        config
363            .config_path
364            .parent()
365            .map_or_else(|| PathBuf::from("."), PathBuf::from)
366            .join("logs")
367    };
368
369    let stderr_log = logs_dir.join("daemon.stderr.log");
370    let stdout_log = logs_dir.join("daemon.stdout.log");
371
372    // Prefer stderr log (most informative), fall back to stdout
373    let log_file = if stderr_log.exists() {
374        stderr_log
375    } else if stdout_log.exists() {
376        stdout_log
377    } else {
378        bail!(
379            "No log files found in {}. Is the service installed?",
380            logs_dir.display()
381        );
382    };
383
384    if follow {
385        let status = Command::new("tail")
386            .args(["-n", &lines.to_string(), "-f"])
387            .arg(&log_file)
388            .status()
389            .context("Failed to run tail")?;
390        if !status.success() {
391            bail!("tail exited with non-zero status");
392        }
393    } else {
394        let status = Command::new("tail")
395            .args(["-n", &lines.to_string()])
396            .arg(&log_file)
397            .status()
398            .context("Failed to run tail")?;
399        if !status.success() {
400            bail!("tail exited with non-zero status");
401        }
402    }
403    Ok(())
404}
405
406fn logs_linux(config: &Config, init_system: InitSystem, lines: usize, follow: bool) -> Result<()> {
407    match init_system {
408        InitSystem::Systemd => {
409            let mut args = vec![
410                "--user".to_string(),
411                "-u".to_string(),
412                "zeroclaw.service".to_string(),
413                "-n".to_string(),
414                lines.to_string(),
415                "--no-pager".to_string(),
416            ];
417            if follow {
418                args.push("-f".to_string());
419            }
420            let status = Command::new("journalctl")
421                .args(&args)
422                .status()
423                .context("Failed to run journalctl")?;
424            if !status.success() {
425                bail!("journalctl exited with non-zero status");
426            }
427        }
428        InitSystem::Openrc => {
429            // OpenRC logs go to /var/log/zeroclaw/error.log (as configured in the init script)
430            let log_file = Path::new("/var/log/zeroclaw/error.log");
431            if !log_file.exists() {
432                // Fall back to access log
433                let access_log = Path::new("/var/log/zeroclaw/access.log");
434                if !access_log.exists() {
435                    bail!("No log files found at /var/log/zeroclaw/. Is the service installed?");
436                }
437                return tail_file(access_log, lines, follow);
438            }
439            tail_file(log_file, lines, follow)?;
440        }
441        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
442    }
443    let _ = config;
444    Ok(())
445}
446
447fn logs_windows(config: &Config, lines: usize, follow: bool) -> Result<()> {
448    let logs_dir = config
449        .config_path
450        .parent()
451        .map_or_else(|| PathBuf::from("."), PathBuf::from)
452        .join("logs");
453
454    let stderr_log = logs_dir.join("daemon.stderr.log");
455    let stdout_log = logs_dir.join("daemon.stdout.log");
456
457    let log_file = if stderr_log.exists() {
458        stderr_log
459    } else if stdout_log.exists() {
460        stdout_log
461    } else {
462        bail!(
463            "No log files found in {}. Is the service installed?",
464            logs_dir.display()
465        );
466    };
467
468    if follow {
469        // Windows: use PowerShell Get-Content -Wait for tail -f equivalent
470        let status = Command::new("powershell")
471            .args([
472                "-Command",
473                &format!(
474                    "Get-Content -Path '{}' -Tail {} -Wait",
475                    log_file.display().to_string(),
476                    lines
477                ),
478            ])
479            .status()
480            .context("Failed to run PowerShell Get-Content")?;
481        if !status.success() {
482            bail!("PowerShell Get-Content exited with non-zero status");
483        }
484    } else {
485        let status = Command::new("powershell")
486            .args([
487                "-Command",
488                &format!(
489                    "Get-Content -Path '{}' -Tail {}",
490                    log_file.display().to_string(),
491                    lines
492                ),
493            ])
494            .status()
495            .context("Failed to run PowerShell Get-Content")?;
496        if !status.success() {
497            bail!("PowerShell Get-Content exited with non-zero status");
498        }
499    }
500    Ok(())
501}
502
503/// Tail a log file using the system `tail` command.
504fn tail_file(path: &Path, lines: usize, follow: bool) -> Result<()> {
505    let mut args = vec!["-n".to_string(), lines.to_string()];
506    if follow {
507        args.push("-f".to_string());
508    }
509    let status = Command::new("tail")
510        .args(&args)
511        .arg(path)
512        .status()
513        .context("Failed to run tail")?;
514    if !status.success() {
515        bail!("tail exited with non-zero status");
516    }
517    Ok(())
518}
519
520pub fn uninstall(config: &Config, init_system: InitSystem) -> Result<()> {
521    stop(config, init_system)?;
522
523    if cfg!(target_os = "macos") {
524        let file = macos_service_file()?;
525        if file.exists() {
526            fs::remove_file(&file)
527                .with_context(|| format!("Failed to remove {}", file.display().to_string()))?;
528        }
529        println!("✅ Service uninstalled ({})", file.display().to_string());
530        return Ok(());
531    }
532
533    if cfg!(target_os = "linux") {
534        let resolved = init_system.resolve()?;
535        return uninstall_linux(config, resolved);
536    }
537
538    if cfg!(target_os = "windows") {
539        let task_name = windows_task_name();
540        let _ = run_checked(Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"]));
541        // Remove the wrapper script. It now lives in the config dir root, but
542        // older installs left it under logs/ — clean up both so an upgrade
543        // doesn't strand the legacy copy.
544        let base_dir = config
545            .config_path
546            .parent()
547            .map_or_else(|| PathBuf::from("."), PathBuf::from);
548        for wrapper in [
549            base_dir.join("zeroclaw-daemon.cmd"),
550            base_dir.join("logs").join("zeroclaw-daemon.cmd"),
551        ] {
552            if wrapper.exists() {
553                fs::remove_file(&wrapper).ok();
554            }
555        }
556        println!("✅ Service uninstalled");
557        return Ok(());
558    }
559
560    anyhow::bail!("Service management is supported on macOS and Linux only")
561}
562
563fn uninstall_linux(config: &Config, init_system: InitSystem) -> Result<()> {
564    match init_system {
565        InitSystem::Systemd => {
566            let file = linux_service_file(config)?;
567            if file.exists() {
568                fs::remove_file(&file)
569                    .with_context(|| format!("Failed to remove {}", file.display().to_string()))?;
570            }
571            let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]));
572            println!("✅ Service uninstalled ({})", file.display().to_string());
573        }
574        InitSystem::Openrc => {
575            let init_script = Path::new("/etc/init.d/zeroclaw");
576            if init_script.exists() {
577                if let Err(err) =
578                    run_checked(Command::new("rc-update").args(["del", "zeroclaw", "default"]))
579                {
580                    eprintln!(
581                        "⚠️  Warning: Could not remove zeroclaw from OpenRC default runlevel: {err}"
582                    );
583                }
584                fs::remove_file(init_script).with_context(|| {
585                    format!("Failed to remove {}", init_script.display().to_string())
586                })?;
587            }
588            println!("✅ Service uninstalled (/etc/init.d/zeroclaw)");
589        }
590        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
591    }
592    Ok(())
593}
594
595/// Detect if the executable lives under a Homebrew prefix and return the
596/// corresponding `var/zeroclaw` directory.
597///
598/// Homebrew installs binaries into `<prefix>/Cellar/<formula>/<version>/bin/`
599/// and symlinks them through `<prefix>/bin/` and `<prefix>/opt/<formula>/`.
600/// The canonical `var` directory is `<prefix>/var`.
601pub fn homebrew_var_dir_from_exe(exe: &Path) -> Option<PathBuf> {
602    let resolved = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
603    let exe = resolved.as_path();
604
605    if let Some(cellar) = exe
606        .ancestors()
607        .find(|path| path.file_name().is_some_and(|name| name == "Cellar"))
608    {
609        return cellar
610            .parent()
611            .map(|prefix| prefix.join("var").join("zeroclaw"));
612    }
613
614    let prefix = exe.parent()?.parent()?;
615    prefix
616        .join("Cellar")
617        .is_dir()
618        .then(|| prefix.join("var").join("zeroclaw"))
619}
620
621#[cfg(test)]
622mod homebrew_tests {
623    use super::*;
624
625    #[test]
626    fn homebrew_var_dir_from_exe_detects_cellar_path() {
627        let exe = PathBuf::from("/opt/homebrew/Cellar/zeroclaw/1.2.3/bin/zeroclaw");
628        let var_dir = homebrew_var_dir_from_exe(&exe);
629        assert_eq!(var_dir, Some(PathBuf::from("/opt/homebrew/var/zeroclaw")));
630    }
631
632    #[test]
633    fn homebrew_var_dir_from_exe_detects_intel_cellar_path() {
634        let exe = PathBuf::from("/usr/local/Cellar/zeroclaw/1.0.0/bin/zeroclaw");
635        let var_dir = homebrew_var_dir_from_exe(&exe);
636        assert_eq!(var_dir, Some(PathBuf::from("/usr/local/var/zeroclaw")));
637    }
638
639    #[test]
640    fn homebrew_var_dir_from_exe_ignores_non_homebrew_path() {
641        let exe = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
642        let var_dir = homebrew_var_dir_from_exe(&exe);
643        assert_eq!(var_dir, None);
644    }
645
646    #[cfg(unix)]
647    #[test]
648    fn homebrew_var_dir_from_exe_detects_opt_symlink_layout() {
649        let temp = tempfile::tempdir().expect("tempdir");
650        let prefix = temp.path().join("homebrew");
651        let cellar_bin = prefix.join("Cellar/zeroclaw/1.2.3/bin");
652        std::fs::create_dir_all(&cellar_bin).expect("create Cellar binary dir");
653        let cellar_exe = cellar_bin.join("zeroclaw");
654        std::fs::write(&cellar_exe, "").expect("create fake executable");
655
656        let opt_parent = prefix.join("opt");
657        std::fs::create_dir_all(&opt_parent).expect("create opt dir");
658        std::os::unix::fs::symlink(
659            prefix.join("Cellar/zeroclaw/1.2.3"),
660            opt_parent.join("zeroclaw"),
661        )
662        .expect("create opt symlink");
663
664        let expected_prefix = prefix
665            .canonicalize()
666            .expect("canonicalize fake Homebrew prefix");
667        let var_dir = homebrew_var_dir_from_exe(&prefix.join("opt/zeroclaw/bin/zeroclaw"));
668        assert_eq!(var_dir, Some(expected_prefix.join("var/zeroclaw")));
669    }
670}
671
672fn install_macos(config: &Config) -> Result<()> {
673    let file = macos_service_file()?;
674    if let Some(parent) = file.parent() {
675        fs::create_dir_all(parent)?;
676    }
677
678    let exe = std::env::current_exe().context("Failed to resolve current executable")?;
679
680    // When installed via Homebrew, use the Homebrew var directory for runtime
681    // data so that `brew services start zeroclaw` works out of the box.
682    let homebrew_var_dir = homebrew_var_dir_from_exe(&exe);
683    if let Some(ref var_dir) = homebrew_var_dir {
684        fs::create_dir_all(var_dir).with_context(|| {
685            format!(
686                "Failed to create Homebrew var directory: {}",
687                var_dir.display()
688            )
689        })?;
690    }
691
692    let logs_dir = if let Some(ref var_dir) = homebrew_var_dir {
693        var_dir.join("logs")
694    } else {
695        config
696            .config_path
697            .parent()
698            .map_or_else(|| PathBuf::from("."), PathBuf::from)
699            .join("logs")
700    };
701    fs::create_dir_all(&logs_dir)?;
702
703    let stdout = logs_dir.join("daemon.stdout.log");
704    let stderr = logs_dir.join("daemon.stderr.log");
705
706    let plist =
707        render_macos_launch_agent_plist(&exe, &stdout, &stderr, homebrew_var_dir.as_deref());
708
709    fs::write(&file, plist)?;
710    println!("✅ Installed launchd service: {}", file.display());
711    if let Some(ref var_dir) = homebrew_var_dir {
712        println!("   Homebrew var: {}", var_dir.display());
713    }
714    println!("   Start with: zeroclaw service start");
715    Ok(())
716}
717
718/// Renders the macOS LaunchAgent plist; path arguments are XML-escaped before interpolation,
719/// and the caller is responsible for writing the returned XML to the plist path.
720fn render_macos_launch_agent_plist(
721    exe: &Path,
722    stdout: &Path,
723    stderr: &Path,
724    homebrew_var_dir: Option<&Path>,
725) -> String {
726    // When running under Homebrew, inject ZEROCLAW_CONFIG_DIR and
727    // WorkingDirectory so the daemon finds its data in the Homebrew prefix.
728    let env_section = if let Some(var_dir) = homebrew_var_dir {
729        format!(
730            r#"  <key>EnvironmentVariables</key>
731  <dict>
732    <key>ZEROCLAW_CONFIG_DIR</key>
733    <string>{config_dir}</string>
734  </dict>
735  <key>WorkingDirectory</key>
736  <string>{working_dir}</string>
737"#,
738            config_dir = xml_escape(&var_dir.display().to_string()),
739            working_dir = xml_escape(&var_dir.display().to_string()),
740        )
741    } else {
742        String::new()
743    };
744
745    format!(
746        r#"<?xml version="1.0" encoding="UTF-8"?>
747<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
748<plist version="1.0">
749<dict>
750  <key>Label</key>
751  <string>{label}</string>
752  <key>ProgramArguments</key>
753  <array>
754    <string>{exe}</string>
755    <string>daemon</string>
756  </array>
757  <key>RunAtLoad</key>
758  <true/>
759  <key>KeepAlive</key>
760  <true/>
761{env_section}  <key>StandardOutPath</key>
762  <string>{stdout}</string>
763  <key>StandardErrorPath</key>
764  <string>{stderr}</string>
765</dict>
766</plist>
767"#,
768        label = SERVICE_LABEL,
769        exe = xml_escape(&exe.display().to_string()),
770        env_section = env_section,
771        stdout = xml_escape(&stdout.display().to_string()),
772        stderr = xml_escape(&stderr.display().to_string())
773    )
774}
775
776fn install_linux(config: &Config, init_system: InitSystem) -> Result<()> {
777    match init_system {
778        InitSystem::Systemd => install_linux_systemd(config),
779        InitSystem::Openrc => install_linux_openrc(config),
780        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
781    }
782}
783
784fn install_linux_systemd(config: &Config) -> Result<()> {
785    let file = linux_service_file(config)?;
786    if let Some(parent) = file.parent() {
787        fs::create_dir_all(parent)?;
788    }
789
790    let exe = std::env::current_exe().context("Failed to resolve current executable")?;
791    let unit = format!(
792        "[Unit]\n\
793         Description=ZeroClaw daemon\n\
794         After=network.target\n\
795         \n\
796         [Service]\n\
797         Type=simple\n\
798         ExecStart={exe} daemon\n\
799         Restart=always\n\
800         RestartSec=3\n\
801         # Ensure HOME is set so headless browsers can create profile/cache dirs.\n\
802         Environment=HOME=%h\n\
803         # Allow inheriting DISPLAY and XDG_RUNTIME_DIR from the user session\n\
804         # so graphical/headless browsers can function correctly.\n\
805         PassEnvironment=DISPLAY XDG_RUNTIME_DIR\n\
806         \n\
807         [Install]\n\
808         WantedBy=default.target\n",
809        exe = exe.display()
810    );
811
812    fs::write(&file, unit)?;
813    let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]));
814    let _ = run_checked(Command::new("systemctl").args(["--user", "enable", "zeroclaw.service"]));
815    println!(
816        "✅ Installed systemd user service: {}",
817        file.display().to_string()
818    );
819    println!("   Start with: zeroclaw service start");
820    Ok(())
821}
822
823/// Check if the current process is running as root (Unix only)
824#[cfg(unix)]
825fn is_root() -> bool {
826    // SAFETY: `getuid()` is a simple system call that returns the real user ID of the calling
827    // process. It is always safe to call as it takes no arguments and returns a scalar value.
828    // This is a well-established pattern in Rust for getting the current user ID.
829    unsafe { libc::getuid() == 0 }
830}
831
832#[cfg(not(unix))]
833fn is_root() -> bool {
834    false
835}
836
837/// Check if the zeroclaw user exists and has expected properties.
838/// Returns Ok if user doesn't exist (OpenRC will handle creation or fail gracefully).
839/// Returns error if user exists but has unexpected properties.
840fn check_zeroclaw_user() -> Result<()> {
841    let output = Command::new("getent").args(["passwd", "zeroclaw"]).output();
842    let is_alpine = Path::new("/etc/alpine-release").exists();
843
844    let (del_cmd, add_cmd) = if is_alpine {
845        (
846            "deluser zeroclaw && delgroup zeroclaw",
847            "addgroup -S zeroclaw && adduser -S -s /sbin/nologin -H -D -G zeroclaw zeroclaw",
848        )
849    } else {
850        ("userdel zeroclaw", "useradd -r -s /sbin/nologin zeroclaw")
851    };
852
853    match output {
854        Ok(output) if output.status.success() => {
855            let passwd_entry = String::from_utf8_lossy(&output.stdout);
856            let parts: Vec<&str> = passwd_entry.split(':').collect();
857            if parts.len() >= 7 {
858                let uid = parts[2];
859                let gid = parts[3];
860                let home = parts[5];
861                let shell = parts[6];
862
863                if uid.parse::<u32>().unwrap_or(999) >= 1000 {
864                    bail!(
865                        "User 'zeroclaw' exists but has unexpected UID {} (expected system UID < 1000).\n\
866                         Recreate with: sudo {} && sudo {}",
867                        uid,
868                        del_cmd,
869                        add_cmd
870                    );
871                }
872
873                if !shell.contains("nologin") && !shell.contains("false") {
874                    bail!(
875                        "User 'zeroclaw' exists but has unexpected shell '{}'.\n\
876                         Expected nologin/false for security. Fix with: sudo {} && sudo {}",
877                        shell,
878                        del_cmd,
879                        add_cmd
880                    );
881                }
882
883                if home != "/var/lib/zeroclaw" && home != "/nonexistent" {
884                    eprintln!(
885                        "⚠️  Warning: zeroclaw user has home directory '{}' (expected /var/lib/zeroclaw or /nonexistent)",
886                        home
887                    );
888                }
889
890                let _ = gid;
891            }
892            Ok(())
893        }
894        _ => Ok(()),
895    }
896}
897
898fn ensure_zeroclaw_user() -> Result<()> {
899    let output = Command::new("getent").args(["passwd", "zeroclaw"]).output();
900    if let Ok(output) = output
901        && output.status.success()
902    {
903        return check_zeroclaw_user();
904    }
905
906    let is_alpine = Path::new("/etc/alpine-release").exists();
907
908    if is_alpine {
909        let group_output = Command::new("getent").args(["group", "zeroclaw"]).output();
910        let group_exists = group_output.map(|o| o.status.success()).unwrap_or(false);
911
912        if !group_exists {
913            let output = Command::new("addgroup")
914                .args(["-S", "zeroclaw"])
915                .output()
916                .context("Failed to create zeroclaw group")?;
917
918            if !output.status.success() {
919                let stderr = String::from_utf8_lossy(&output.stderr);
920                bail!("Failed to create zeroclaw group: {}", stderr.trim());
921            }
922            println!("✅ Created system group: zeroclaw");
923        }
924
925        let output = Command::new("adduser")
926            .args([
927                "-S",
928                "-s",
929                "/sbin/nologin",
930                "-H",
931                "-D",
932                "-G",
933                "zeroclaw",
934                "zeroclaw",
935            ])
936            .output()
937            .context("Failed to create zeroclaw user")?;
938
939        if !output.status.success() {
940            let stderr = String::from_utf8_lossy(&output.stderr);
941            bail!("Failed to create zeroclaw user: {}", stderr.trim());
942        }
943    } else {
944        let output = Command::new("useradd")
945            .args(["-r", "-s", "/sbin/nologin", "zeroclaw"])
946            .output()
947            .context("Failed to create zeroclaw user")?;
948
949        if !output.status.success() {
950            let stderr = String::from_utf8_lossy(&output.stderr);
951            bail!("Failed to create zeroclaw user: {}", stderr.trim());
952        }
953    }
954
955    println!("✅ Created system user: zeroclaw");
956    Ok(())
957}
958
959/// Change ownership of a path to zeroclaw:zeroclaw
960#[cfg(unix)]
961fn chown_to_zeroclaw(path: &Path) -> Result<()> {
962    let output = Command::new("chown")
963        .args(["zeroclaw:zeroclaw", &path.to_string_lossy()])
964        .output()
965        .context("Failed to run chown")?;
966
967    if !output.status.success() {
968        let stderr = String::from_utf8_lossy(&output.stderr);
969        bail!(
970            "Failed to change ownership of {} to zeroclaw:zeroclaw: {}",
971            path.display().to_string(),
972            stderr.trim(),
973        );
974    }
975    Ok(())
976}
977
978#[cfg(not(unix))]
979fn chown_to_zeroclaw(_path: &Path) -> Result<()> {
980    Ok(())
981}
982
983#[cfg(unix)]
984fn chown_recursive_to_zeroclaw(path: &Path) -> Result<()> {
985    let output = Command::new("chown")
986        .args(["-R", "zeroclaw:zeroclaw", &path.to_string_lossy()])
987        .output()
988        .context("Failed to run recursive chown")?;
989
990    if !output.status.success() {
991        let stderr = String::from_utf8_lossy(&output.stderr);
992        bail!(
993            "Failed to recursively change ownership of {} to zeroclaw:zeroclaw: {}",
994            path.display().to_string(),
995            stderr.trim(),
996        );
997    }
998
999    Ok(())
1000}
1001
1002#[cfg(not(unix))]
1003fn chown_recursive_to_zeroclaw(_path: &Path) -> Result<()> {
1004    Ok(())
1005}
1006
1007fn copy_dir_recursive(source: &Path, target: &Path) -> Result<()> {
1008    fs::create_dir_all(target).with_context(|| {
1009        format!(
1010            "Failed to create directory {}",
1011            target.display().to_string()
1012        )
1013    })?;
1014
1015    for entry in fs::read_dir(source)
1016        .with_context(|| format!("Failed to read directory {}", source.display().to_string()))?
1017    {
1018        let entry = entry?;
1019        let source_path = entry.path();
1020        let target_path = target.join(entry.file_name());
1021        let file_type = entry
1022            .file_type()
1023            .with_context(|| format!("Failed to inspect {}", source_path.display().to_string()))?;
1024
1025        if file_type.is_dir() {
1026            copy_dir_recursive(&source_path, &target_path)?;
1027        } else if file_type.is_file() {
1028            if target_path.exists() {
1029                continue;
1030            }
1031            fs::copy(&source_path, &target_path).with_context(|| {
1032                format!(
1033                    "Failed to copy file {} -> {}",
1034                    source_path.display().to_string(),
1035                    target_path.display()
1036                )
1037            })?;
1038        }
1039    }
1040
1041    Ok(())
1042}
1043
1044fn resolve_invoking_user_config_dir() -> Option<PathBuf> {
1045    let sudo_user = std::env::var("SUDO_USER")
1046        .ok()
1047        .map(|value| value.trim().to_string())
1048        .filter(|value| !value.is_empty() && value != "root");
1049
1050    if let Some(user) = sudo_user
1051        && let Ok(output) = Command::new("getent").args(["passwd", &user]).output()
1052        && output.status.success()
1053    {
1054        let entry = String::from_utf8_lossy(&output.stdout);
1055        let fields: Vec<&str> = entry.trim().split(':').collect();
1056        if fields.len() >= 6 {
1057            return Some(PathBuf::from(fields[5]).join(".zeroclaw"));
1058        }
1059    }
1060
1061    std::env::var("HOME")
1062        .ok()
1063        .map(PathBuf::from)
1064        .map(|home| home.join(".zeroclaw"))
1065}
1066
1067fn migrate_openrc_runtime_state_if_needed(config_dir: &Path) -> Result<()> {
1068    let target_config = config_dir.join("config.toml");
1069    if target_config.exists() {
1070        println!(
1071            "✅ Reusing existing OpenRC config at {}",
1072            target_config.display()
1073        );
1074        return Ok(());
1075    }
1076
1077    let Some(source_dir) = resolve_invoking_user_config_dir() else {
1078        return Ok(());
1079    };
1080
1081    let source_config = source_dir.join("config.toml");
1082    if !source_config.exists() {
1083        return Ok(());
1084    }
1085
1086    copy_dir_recursive(&source_dir, config_dir)?;
1087    println!(
1088        "✅ Migrated runtime state from {} to {}",
1089        source_dir.display().to_string(),
1090        config_dir.display()
1091    );
1092    Ok(())
1093}
1094
1095#[cfg(unix)]
1096fn shell_single_quote(raw: &str) -> String {
1097    format!("'{}'", raw.replace('\'', "'\"'\"'"))
1098}
1099
1100#[cfg(unix)]
1101fn build_openrc_writability_probe_command(path: &Path, has_runuser: bool) -> (String, Vec<String>) {
1102    let probe = format!("test -w {}", shell_single_quote(&path.to_string_lossy()));
1103    if has_runuser {
1104        (
1105            "runuser".to_string(),
1106            vec![
1107                "-u".to_string(),
1108                "zeroclaw".to_string(),
1109                "--".to_string(),
1110                "sh".to_string(),
1111                "-c".to_string(),
1112                probe,
1113            ],
1114        )
1115    } else {
1116        (
1117            "su".to_string(),
1118            vec![
1119                "-s".to_string(),
1120                "/bin/sh".to_string(),
1121                "-c".to_string(),
1122                probe,
1123                "zeroclaw".to_string(),
1124            ],
1125        )
1126    }
1127}
1128
1129#[cfg(unix)]
1130fn ensure_openrc_runtime_path_writable(path: &Path) -> Result<()> {
1131    let has_runuser = which::which("runuser").is_ok();
1132    let (program, args) = build_openrc_writability_probe_command(path, has_runuser);
1133    let output = Command::new(&program)
1134        .args(args.iter().map(String::as_str))
1135        .output()
1136        .with_context(|| {
1137            format!(
1138                "Failed to verify OpenRC runtime write access for {}",
1139                path.display()
1140            )
1141        })?;
1142
1143    if !output.status.success() {
1144        let stderr = String::from_utf8_lossy(&output.stderr);
1145        let details = if stderr.trim().is_empty() {
1146            "write-access probe failed"
1147        } else {
1148            stderr.trim()
1149        };
1150        bail!(
1151            "OpenRC runtime user 'zeroclaw' cannot write {} ({details}). \
1152             Re-run `sudo zeroclaw service install` and ensure ownership is zeroclaw:zeroclaw.",
1153            path.display().to_string(),
1154        );
1155    }
1156
1157    Ok(())
1158}
1159
1160#[cfg(unix)]
1161fn ensure_openrc_runtime_dirs_writable(
1162    config_dir: &Path,
1163    workspace_dir: &Path,
1164    log_dir: &Path,
1165) -> Result<()> {
1166    for path in [config_dir, workspace_dir, log_dir] {
1167        ensure_openrc_runtime_path_writable(path)?;
1168    }
1169    Ok(())
1170}
1171
1172#[cfg(not(unix))]
1173fn ensure_openrc_runtime_dirs_writable(
1174    _config_dir: &Path,
1175    _workspace_dir: &Path,
1176    _log_dir: &Path,
1177) -> Result<()> {
1178    Ok(())
1179}
1180
1181/// Warn if the binary path is in a user home directory
1182fn warn_if_binary_in_home(exe_path: &Path) {
1183    let path_str = exe_path.to_string_lossy();
1184    if path_str.contains("/home/") || path_str.contains(".cargo/bin") {
1185        eprintln!(
1186            "⚠️  Warning: Binary path '{}' appears to be in a user home directory.\n\
1187             For system-wide OpenRC service, consider installing to /usr/local/bin:\n\
1188             sudo cp '{}' /usr/local/bin/zeroclaw",
1189            exe_path.display().to_string(),
1190            exe_path.display()
1191        );
1192    }
1193}
1194
1195/// Generate OpenRC init script content (pure function for testability)
1196fn generate_openrc_script(exe_path: &Path, config_dir: &Path) -> String {
1197    format!(
1198        r#"#!/sbin/openrc-run
1199
1200name="zeroclaw"
1201description="ZeroClaw daemon"
1202
1203command="{exe}"
1204command_args="--config-dir {config_dir} daemon"
1205command_background="yes"
1206command_user="zeroclaw:zeroclaw"
1207pidfile="/run/${{RC_SVCNAME}}.pid"
1208umask 027
1209output_log="/var/log/zeroclaw/access.log"
1210error_log="/var/log/zeroclaw/error.log"
1211
1212# Provide HOME so headless browsers can create profile/cache directories.
1213# Without this, Chromium/Firefox fail with sandbox or profile errors.
1214export HOME="/var/lib/zeroclaw"
1215
1216depend() {{
1217    need net
1218    after firewall
1219}}
1220
1221start_pre() {{
1222    checkpath --directory --owner zeroclaw:zeroclaw --mode 0750 /var/lib/zeroclaw
1223}}
1224"#,
1225        exe = exe_path.display().to_string(),
1226        config_dir = config_dir.display().to_string(),
1227    )
1228}
1229
1230fn resolve_openrc_executable() -> Result<PathBuf> {
1231    let preferred = Path::new("/usr/local/bin/zeroclaw");
1232    if preferred.exists() {
1233        return Ok(preferred.to_path_buf());
1234    }
1235
1236    let exe = std::env::current_exe().context("Failed to resolve current executable")?;
1237    Ok(exe)
1238}
1239
1240fn install_linux_openrc(config: &Config) -> Result<()> {
1241    if !is_root() {
1242        bail!(
1243            "OpenRC service installation requires root privileges.\n\
1244             Please run with sudo: sudo zeroclaw service install"
1245        );
1246    }
1247
1248    ensure_zeroclaw_user()?;
1249
1250    let exe = resolve_openrc_executable()?;
1251    warn_if_binary_in_home(&exe);
1252
1253    let config_dir = Path::new("/etc/zeroclaw");
1254    let workspace_dir = config_dir.join("workspace");
1255    let log_dir = Path::new("/var/log/zeroclaw");
1256
1257    if !config_dir.exists() {
1258        fs::create_dir_all(config_dir)
1259            .with_context(|| format!("Failed to create {}", config_dir.display().to_string()))?;
1260        #[cfg(unix)]
1261        {
1262            use std::os::unix::fs::PermissionsExt;
1263            fs::set_permissions(config_dir, fs::Permissions::from_mode(0o755)).with_context(
1264                || {
1265                    format!(
1266                        "Failed to set permissions on {}",
1267                        config_dir.display().to_string()
1268                    )
1269                },
1270            )?;
1271        }
1272        println!("✅ Created directory: {}", config_dir.display().to_string());
1273    }
1274
1275    migrate_openrc_runtime_state_if_needed(config_dir)?;
1276
1277    if !workspace_dir.exists() {
1278        fs::create_dir_all(&workspace_dir)
1279            .with_context(|| format!("Failed to create {}", workspace_dir.display().to_string()))?;
1280        #[cfg(unix)]
1281        {
1282            use std::os::unix::fs::PermissionsExt;
1283            fs::set_permissions(&workspace_dir, fs::Permissions::from_mode(0o750)).with_context(
1284                || {
1285                    format!(
1286                        "Failed to set permissions on {}",
1287                        workspace_dir.display().to_string()
1288                    )
1289                },
1290            )?;
1291        }
1292        chown_to_zeroclaw(&workspace_dir)?;
1293        println!(
1294            "✅ Created directory: {} (owned by zeroclaw:zeroclaw)",
1295            workspace_dir.display()
1296        );
1297    }
1298
1299    #[cfg(unix)]
1300    {
1301        use std::os::unix::fs::PermissionsExt;
1302        fs::set_permissions(&workspace_dir, fs::Permissions::from_mode(0o750)).with_context(
1303            || {
1304                format!(
1305                    "Failed to set permissions on {}",
1306                    workspace_dir.display().to_string()
1307                )
1308            },
1309        )?;
1310    }
1311
1312    #[cfg(unix)]
1313    {
1314        use std::os::unix::fs::PermissionsExt;
1315        fs::set_permissions(config_dir, fs::Permissions::from_mode(0o755)).with_context(|| {
1316            format!(
1317                "Failed to set permissions on {}",
1318                config_dir.display().to_string()
1319            )
1320        })?;
1321        let config_path = config_dir.join("config.toml");
1322        if config_path.exists() {
1323            fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)).with_context(
1324                || {
1325                    format!(
1326                        "Failed to set permissions on {}",
1327                        config_path.display().to_string()
1328                    )
1329                },
1330            )?;
1331        }
1332        let secret_key_path = config_dir.join(".secret_key");
1333        if secret_key_path.exists() {
1334            fs::set_permissions(&secret_key_path, fs::Permissions::from_mode(0o600)).with_context(
1335                || {
1336                    format!(
1337                        "Failed to set permissions on {}",
1338                        secret_key_path.display().to_string()
1339                    )
1340                },
1341            )?;
1342        }
1343    }
1344
1345    chown_recursive_to_zeroclaw(config_dir)?;
1346
1347    let created_log_dir = !log_dir.exists();
1348    if created_log_dir {
1349        fs::create_dir_all(log_dir)
1350            .with_context(|| format!("Failed to create {}", log_dir.display().to_string()))?;
1351        #[cfg(unix)]
1352        {
1353            use std::os::unix::fs::PermissionsExt;
1354            fs::set_permissions(log_dir, fs::Permissions::from_mode(0o750)).with_context(|| {
1355                format!(
1356                    "Failed to set permissions on {}",
1357                    log_dir.display().to_string()
1358                )
1359            })?;
1360        }
1361    }
1362
1363    chown_to_zeroclaw(log_dir)?;
1364
1365    ensure_openrc_runtime_dirs_writable(config_dir, &workspace_dir, log_dir)?;
1366
1367    if created_log_dir {
1368        println!(
1369            "✅ Created directory: {} (owned by zeroclaw:zeroclaw)",
1370            log_dir.display()
1371        );
1372    }
1373
1374    let init_script = generate_openrc_script(&exe, config_dir);
1375    let init_path = Path::new("/etc/init.d/zeroclaw");
1376    fs::write(init_path, init_script)
1377        .with_context(|| format!("Failed to write {}", init_path.display().to_string()))?;
1378
1379    #[cfg(unix)]
1380    {
1381        use std::os::unix::fs::PermissionsExt;
1382        fs::set_permissions(init_path, fs::Permissions::from_mode(0o755)).with_context(|| {
1383            format!(
1384                "Failed to set permissions on {}",
1385                init_path.display().to_string()
1386            )
1387        })?;
1388    }
1389
1390    run_checked(Command::new("rc-update").args(["add", "zeroclaw", "default"]))?;
1391    println!("✅ Installed OpenRC service: /etc/init.d/zeroclaw");
1392    println!("   Config path: /etc/zeroclaw/config.toml");
1393    println!("   Start with: sudo zeroclaw service start");
1394    let _ = config;
1395    Ok(())
1396}
1397
1398fn install_windows(config: &Config) -> Result<()> {
1399    let exe = std::env::current_exe().context("Failed to resolve current executable")?;
1400    let base_dir = config
1401        .config_path
1402        .parent()
1403        .map_or_else(|| PathBuf::from("."), PathBuf::from);
1404    let logs_dir = base_dir.join("logs");
1405    fs::create_dir_all(&logs_dir)?;
1406
1407    // The launch wrapper is an install artifact, not log output — keep it in
1408    // the config dir root so the logs dir holds only `.log` files. (Previously
1409    // it landed in logs/, where a `.cmd` next to the daemon's log files reads
1410    // as misplaced.)
1411    let wrapper = base_dir.join("zeroclaw-daemon.cmd");
1412    let stdout_log = logs_dir.join("daemon.stdout.log");
1413    let stderr_log = logs_dir.join("daemon.stderr.log");
1414
1415    let wrapper_content = format!(
1416        "@echo off\r\n\"{}\" daemon >>\"{}\" 2>>\"{}\"",
1417        exe.display().to_string(),
1418        stdout_log.display().to_string(),
1419        stderr_log.display()
1420    );
1421    fs::write(&wrapper, &wrapper_content)?;
1422
1423    let task_name = windows_task_name();
1424
1425    // Remove any existing task first (ignore errors if it doesn't exist)
1426    let _ = Command::new("schtasks")
1427        .args(["/Delete", "/TN", task_name, "/F"])
1428        .output();
1429
1430    // Run at the invoking user's normal privilege (LIMITED), not HIGHEST.
1431    // This is a per-user ONLOGON task driving a user-level daemon; running it
1432    // elevated makes the daemon's RPC pipe owned by an elevated token, so a
1433    // non-elevated `zerocode` can't connect unless it too is run as admin.
1434    // Matching the user's standard token keeps the pipe reachable from the
1435    // normal desktop session.
1436    run_checked(Command::new("schtasks").args([
1437        "/Create",
1438        "/TN",
1439        task_name,
1440        "/SC",
1441        "ONLOGON",
1442        "/TR",
1443        &format!("\"{}\"", wrapper.display().to_string()),
1444        "/RL",
1445        "LIMITED",
1446        "/F",
1447    ]))?;
1448
1449    println!("✅ Installed Windows scheduled task: {}", task_name);
1450    println!("   Wrapper: {}", wrapper.display().to_string());
1451    println!("   Logs: {}", logs_dir.display().to_string());
1452    println!("   Start with: zeroclaw service start");
1453    Ok(())
1454}
1455
1456fn macos_service_file() -> Result<PathBuf> {
1457    let home = directories::UserDirs::new()
1458        .map(|u| u.home_dir().to_path_buf())
1459        .context("Could not find home directory")?;
1460    Ok(home
1461        .join("Library")
1462        .join("LaunchAgents")
1463        .join(format!("{SERVICE_LABEL}.plist")))
1464}
1465
1466fn linux_service_file(config: &Config) -> Result<PathBuf> {
1467    let home = directories::UserDirs::new()
1468        .map(|u| u.home_dir().to_path_buf())
1469        .context("Could not find home directory")?;
1470    let _ = config;
1471    Ok(home
1472        .join(".config")
1473        .join("systemd")
1474        .join("user")
1475        .join("zeroclaw.service"))
1476}
1477
1478fn run_checked(command: &mut Command) -> Result<()> {
1479    let output = command.output().context("Failed to spawn command")?;
1480    if !output.status.success() {
1481        let stderr = String::from_utf8_lossy(&output.stderr);
1482        anyhow::bail!("Command failed: {}", stderr.trim());
1483    }
1484    Ok(())
1485}
1486
1487pub fn run_capture(command: &mut Command) -> Result<String> {
1488    let output = command.output().context("Failed to spawn command")?;
1489    let mut text = String::from_utf8_lossy(&output.stdout).to_string();
1490    if text.trim().is_empty() {
1491        text = String::from_utf8_lossy(&output.stderr).to_string();
1492    }
1493    Ok(text)
1494}
1495
1496pub fn xml_escape(raw: &str) -> String {
1497    raw.replace('&', "&amp;")
1498        .replace('<', "&lt;")
1499        .replace('>', "&gt;")
1500        .replace('"', "&quot;")
1501        .replace('\'', "&apos;")
1502}
1503
1504// Plain `#[cfg(test)]` is intentional: these pure renderer tests have no
1505// integration dependencies and should run in every zeroclaw-runtime test build.
1506#[cfg(test)]
1507mod macos_plist_tests {
1508    use super::*;
1509
1510    #[test]
1511    fn macos_plist_renderer_uses_plain_xml_quotes() {
1512        let plist = render_macos_launch_agent_plist(
1513            Path::new("/opt/homebrew/bin/zeroclaw"),
1514            Path::new("/opt/homebrew/var/zeroclaw/logs/daemon.stdout.log"),
1515            Path::new("/opt/homebrew/var/zeroclaw/logs/daemon.stderr.log"),
1516            Some(Path::new("/opt/homebrew/var/zeroclaw")),
1517        );
1518
1519        assert!(!plist.contains(r#"\""#));
1520        assert!(plist.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
1521        assert!(plist.contains(
1522            r#"<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">"#
1523        ));
1524        assert!(plist.contains(r#"<plist version="1.0">"#));
1525        assert!(plist.contains("<key>EnvironmentVariables</key>"));
1526    }
1527
1528    #[test]
1529    fn macos_plist_renderer_escapes_paths_and_omits_homebrew_section_when_absent() {
1530        let plist = render_macos_launch_agent_plist(
1531            Path::new("/tmp/Zero<&>\"'Claw/bin/zeroclaw"),
1532            Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stdout.log"),
1533            Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stderr.log"),
1534            None,
1535        );
1536
1537        assert!(plist.contains("/tmp/Zero&lt;&amp;&gt;&quot;&apos;Claw/bin/zeroclaw"));
1538        assert!(plist.contains("/tmp/Zero&lt;&amp;&gt;&quot;&apos;Claw/logs/daemon.stdout.log"));
1539        assert!(plist.contains("/tmp/Zero&lt;&amp;&gt;&quot;&apos;Claw/logs/daemon.stderr.log"));
1540        assert!(!plist.contains("<key>EnvironmentVariables</key>"));
1541        assert!(!plist.contains("<key>WorkingDirectory</key>"));
1542    }
1543
1544    #[cfg(target_os = "macos")]
1545    #[test]
1546    fn macos_plist_renderer_emits_plutil_parseable_xml() {
1547        let plist = render_macos_launch_agent_plist(
1548            Path::new("/tmp/Zero<&>\"'Claw/bin/zeroclaw"),
1549            Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stdout.log"),
1550            Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stderr.log"),
1551            Some(Path::new("/tmp/Zero<&>\"'Claw/var/zeroclaw")),
1552        );
1553
1554        let file = std::env::temp_dir().join(format!(
1555            "zeroclaw-launch-agent-plist-{}.plist",
1556            std::process::id()
1557        ));
1558        fs::write(&file, plist).expect("write plist fixture");
1559
1560        let output = Command::new("plutil")
1561            .arg("-lint")
1562            .arg(&file)
1563            .output()
1564            .expect("run plutil");
1565        let _ = fs::remove_file(&file);
1566
1567        assert!(
1568            output.status.success(),
1569            "plutil failed\nstdout:\n{}\nstderr:\n{}",
1570            String::from_utf8_lossy(&output.stdout),
1571            String::from_utf8_lossy(&output.stderr)
1572        );
1573    }
1574}
1575
1576#[cfg(all(test, zeroclaw_root_crate))]
1577mod tests {
1578    use super::*;
1579
1580    #[test]
1581    fn xml_escape_escapes_reserved_chars() {
1582        let escaped = xml_escape("<&>\"' and text");
1583        assert_eq!(escaped, "&lt;&amp;&gt;&quot;&apos; and text");
1584    }
1585
1586    #[cfg(not(target_os = "windows"))]
1587    #[test]
1588    fn run_capture_reads_stdout() {
1589        let out = run_capture(Command::new("sh").args(["-c", "echo hello"]))
1590            .expect("stdout capture should succeed");
1591        assert_eq!(out.trim(), "hello");
1592    }
1593
1594    #[cfg(not(target_os = "windows"))]
1595    #[test]
1596    fn run_capture_falls_back_to_stderr() {
1597        let out = run_capture(Command::new("sh").args(["-c", "echo warn 1>&2"]))
1598            .expect("stderr capture should succeed");
1599        assert_eq!(out.trim(), "warn");
1600    }
1601
1602    #[cfg(not(target_os = "windows"))]
1603    #[test]
1604    fn run_checked_errors_on_non_zero_status() {
1605        let err = run_checked(Command::new("sh").args(["-c", "exit 17"]))
1606            .expect_err("non-zero exit should error");
1607        assert!(err.to_string().contains("Command failed"));
1608    }
1609
1610    #[cfg(not(target_os = "windows"))]
1611    #[test]
1612    fn linux_service_file_has_expected_suffix() {
1613        let file = linux_service_file(&Config::default()).unwrap();
1614        let path = file.to_string_lossy();
1615        assert!(path.ends_with(".config/systemd/user/zeroclaw.service"));
1616    }
1617
1618    #[test]
1619    fn windows_task_name_is_constant() {
1620        assert_eq!(windows_task_name(), "ZeroClaw Daemon");
1621    }
1622
1623    #[cfg(target_os = "windows")]
1624    #[test]
1625    fn run_capture_reads_stdout_windows() {
1626        let out = run_capture(Command::new("cmd").args(["/C", "echo hello"]))
1627            .expect("stdout capture should succeed");
1628        assert_eq!(out.trim(), "hello");
1629    }
1630
1631    #[cfg(target_os = "windows")]
1632    #[test]
1633    fn run_checked_errors_on_non_zero_status_windows() {
1634        let err = run_checked(Command::new("cmd").args(["/C", "exit /b 17"]))
1635            .expect_err("non-zero exit should error");
1636        assert!(err.to_string().contains("Command failed"));
1637    }
1638
1639    #[test]
1640    fn init_system_from_str_parses_valid_values() {
1641        assert_eq!("auto".parse::<InitSystem>().unwrap(), InitSystem::Auto);
1642        assert_eq!("AUTO".parse::<InitSystem>().unwrap(), InitSystem::Auto);
1643        assert_eq!(
1644            "systemd".parse::<InitSystem>().unwrap(),
1645            InitSystem::Systemd
1646        );
1647        assert_eq!(
1648            "SYSTEMD".parse::<InitSystem>().unwrap(),
1649            InitSystem::Systemd
1650        );
1651        assert_eq!("openrc".parse::<InitSystem>().unwrap(), InitSystem::Openrc);
1652        assert_eq!("OPENRC".parse::<InitSystem>().unwrap(), InitSystem::Openrc);
1653    }
1654
1655    #[test]
1656    fn init_system_from_str_rejects_unknown() {
1657        let err = "unknown"
1658            .parse::<InitSystem>()
1659            .expect_err("should reject unknown");
1660        assert!(err.to_string().contains("Unknown init system"));
1661        assert!(err.to_string().contains("Supported: auto, systemd, openrc"));
1662    }
1663
1664    #[test]
1665    fn init_system_default_is_auto() {
1666        assert_eq!(InitSystem::default(), InitSystem::Auto);
1667    }
1668
1669    #[cfg(unix)]
1670    #[test]
1671    fn is_root_matches_system_uid() {
1672        // SAFETY: `getuid()` is a simple system call that returns the real user ID of the calling
1673        // process. It is always safe to call as it takes no arguments and returns a scalar value.
1674        // This test verifies our `is_root()` wrapper returns the same result as the raw syscall.
1675        assert_eq!(is_root(), unsafe { libc::getuid() == 0 });
1676    }
1677
1678    #[test]
1679    fn generate_openrc_script_contains_required_directives() {
1680        use std::path::PathBuf;
1681
1682        let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1683        let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1684
1685        assert!(script.starts_with("#!/sbin/openrc-run"));
1686        assert!(script.contains("name=\"zeroclaw\""));
1687        assert!(script.contains("description=\"ZeroClaw daemon\""));
1688        assert!(script.contains("command=\"/usr/local/bin/zeroclaw\""));
1689        assert!(script.contains("command_args=\"--config-dir /etc/zeroclaw daemon\""));
1690        assert!(!script.contains("env ZEROCLAW_CONFIG_DIR"));
1691        assert!(!script.contains("env ZEROCLAW_WORKSPACE"));
1692        assert!(script.contains("command_background=\"yes\""));
1693        assert!(script.contains("command_user=\"zeroclaw:zeroclaw\""));
1694        assert!(script.contains("pidfile=\"/run/${RC_SVCNAME}.pid\""));
1695        assert!(script.contains("umask 027"));
1696        assert!(script.contains("output_log=\"/var/log/zeroclaw/access.log\""));
1697        assert!(script.contains("error_log=\"/var/log/zeroclaw/error.log\""));
1698        assert!(script.contains("depend()"));
1699        assert!(script.contains("need net"));
1700        assert!(script.contains("after firewall"));
1701    }
1702
1703    #[test]
1704    fn generate_openrc_script_sets_home_for_browser() {
1705        use std::path::PathBuf;
1706
1707        let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1708        let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1709
1710        assert!(
1711            script.contains("export HOME=\"/var/lib/zeroclaw\""),
1712            "OpenRC script must set HOME for headless browser support"
1713        );
1714    }
1715
1716    #[test]
1717    fn generate_openrc_script_creates_home_directory() {
1718        use std::path::PathBuf;
1719
1720        let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1721        let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1722
1723        assert!(
1724            script.contains("start_pre()"),
1725            "OpenRC script must have start_pre to create HOME dir"
1726        );
1727        assert!(
1728            script.contains("checkpath --directory --owner zeroclaw:zeroclaw"),
1729            "start_pre must ensure /var/lib/zeroclaw exists with correct ownership"
1730        );
1731    }
1732
1733    #[test]
1734    fn systemd_unit_contains_home_and_pass_environment() {
1735        let unit = "[Unit]\n\
1736             Description=ZeroClaw daemon\n\
1737             After=network.target\n\
1738             \n\
1739             [Service]\n\
1740             Type=simple\n\
1741             ExecStart=/usr/local/bin/zeroclaw daemon\n\
1742             Restart=always\n\
1743             RestartSec=3\n\
1744             # Ensure HOME is set so headless browsers can create profile/cache dirs.\n\
1745             Environment=HOME=%h\n\
1746             # Allow inheriting DISPLAY and XDG_RUNTIME_DIR from the user session\n\
1747             # so graphical/headless browsers can function correctly.\n\
1748             PassEnvironment=DISPLAY XDG_RUNTIME_DIR\n\
1749             \n\
1750             [Install]\n\
1751             WantedBy=default.target\n"
1752            .to_string();
1753
1754        assert!(
1755            unit.contains("Environment=HOME=%h"),
1756            "systemd unit must set HOME for headless browser support"
1757        );
1758        assert!(
1759            unit.contains("PassEnvironment=DISPLAY XDG_RUNTIME_DIR"),
1760            "systemd unit must pass through display/runtime env vars"
1761        );
1762    }
1763
1764    #[test]
1765    fn warn_if_binary_in_home_detects_home_path() {
1766        use std::path::PathBuf;
1767
1768        let home_path = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
1769        assert!(home_path.to_string_lossy().contains("/home/"));
1770        assert!(home_path.to_string_lossy().contains(".cargo/bin"));
1771
1772        let cargo_path = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
1773        assert!(cargo_path.to_string_lossy().contains(".cargo/bin"));
1774
1775        let system_path = PathBuf::from("/usr/local/bin/zeroclaw");
1776        assert!(!system_path.to_string_lossy().contains("/home/"));
1777        assert!(!system_path.to_string_lossy().contains(".cargo/bin"));
1778    }
1779
1780    #[cfg(unix)]
1781    #[test]
1782    fn shell_single_quote_escapes_single_quotes() {
1783        assert_eq!(
1784            shell_single_quote("/tmp/weird'path"),
1785            "'/tmp/weird'\"'\"'path'"
1786        );
1787    }
1788
1789    #[cfg(unix)]
1790    #[test]
1791    fn openrc_writability_probe_prefers_runuser_when_available() {
1792        let (program, args) =
1793            build_openrc_writability_probe_command(Path::new("/etc/zeroclaw"), true);
1794        assert_eq!(program, "runuser");
1795        assert_eq!(
1796            args,
1797            vec![
1798                "-u".to_string(),
1799                "zeroclaw".to_string(),
1800                "--".to_string(),
1801                "sh".to_string(),
1802                "-c".to_string(),
1803                "test -w '/etc/zeroclaw'".to_string()
1804            ]
1805        );
1806    }
1807
1808    #[cfg(unix)]
1809    #[test]
1810    fn openrc_writability_probe_falls_back_to_su() {
1811        let (program, args) =
1812            build_openrc_writability_probe_command(Path::new("/etc/zeroclaw/workspace"), false);
1813        assert_eq!(program, "su");
1814        assert_eq!(
1815            args,
1816            vec![
1817                "-s".to_string(),
1818                "/bin/sh".to_string(),
1819                "-c".to_string(),
1820                "test -w '/etc/zeroclaw/workspace'".to_string(),
1821                "zeroclaw".to_string()
1822            ]
1823        );
1824    }
1825
1826    #[cfg(not(target_os = "windows"))]
1827    #[test]
1828    fn tail_file_errors_on_missing_file() {
1829        let missing = Path::new("/tmp/zeroclaw-test-nonexistent-log-file.log");
1830        let result = tail_file(missing, 10, false);
1831        assert!(result.is_err(), "tail on missing file should fail");
1832    }
1833
1834    #[cfg(not(target_os = "windows"))]
1835    #[test]
1836    fn tail_file_reads_existing_file() {
1837        let dir = tempfile::tempdir().expect("failed to create temp dir");
1838        let log = dir.path().join("test-tail.log");
1839        fs::write(&log, "line1\nline2\nline3\nline4\nline5\n").unwrap();
1840        // tail should succeed on existing file
1841        let result = tail_file(&log, 3, false);
1842        assert!(result.is_ok(), "tail on existing file should succeed");
1843    }
1844
1845    #[test]
1846    fn logs_variant_is_recognized() {
1847        // Ensure the Logs variant can be constructed and matched
1848        let cmd = crate::ServiceCommands::Logs {
1849            lines: 25,
1850            follow: true,
1851        };
1852        match &cmd {
1853            crate::ServiceCommands::Logs { lines, follow } => {
1854                assert_eq!(*lines, 25);
1855                assert!(*follow);
1856            }
1857            _ => panic!("Expected Logs variant"),
1858        }
1859    }
1860}