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
542        let wrapper = config
543            .config_path
544            .parent()
545            .map_or_else(|| PathBuf::from("."), PathBuf::from)
546            .join("logs")
547            .join("zeroclaw-daemon.cmd");
548        if wrapper.exists() {
549            fs::remove_file(&wrapper).ok();
550        }
551        println!("✅ Service uninstalled");
552        return Ok(());
553    }
554
555    anyhow::bail!("Service management is supported on macOS and Linux only")
556}
557
558fn uninstall_linux(config: &Config, init_system: InitSystem) -> Result<()> {
559    match init_system {
560        InitSystem::Systemd => {
561            let file = linux_service_file(config)?;
562            if file.exists() {
563                fs::remove_file(&file)
564                    .with_context(|| format!("Failed to remove {}", file.display().to_string()))?;
565            }
566            let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]));
567            println!("✅ Service uninstalled ({})", file.display().to_string());
568        }
569        InitSystem::Openrc => {
570            let init_script = Path::new("/etc/init.d/zeroclaw");
571            if init_script.exists() {
572                if let Err(err) =
573                    run_checked(Command::new("rc-update").args(["del", "zeroclaw", "default"]))
574                {
575                    eprintln!(
576                        "⚠️  Warning: Could not remove zeroclaw from OpenRC default runlevel: {err}"
577                    );
578                }
579                fs::remove_file(init_script).with_context(|| {
580                    format!("Failed to remove {}", init_script.display().to_string())
581                })?;
582            }
583            println!("✅ Service uninstalled (/etc/init.d/zeroclaw)");
584        }
585        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
586    }
587    Ok(())
588}
589
590/// Detect if the executable lives under a Homebrew prefix and return the
591/// corresponding `var/zeroclaw` directory.
592///
593/// Homebrew installs binaries into `<prefix>/Cellar/<formula>/<version>/bin/`
594/// and symlinks them through `<prefix>/bin/` and `<prefix>/opt/<formula>/`.
595/// The canonical `var` directory is `<prefix>/var`.
596pub fn homebrew_var_dir_from_exe(exe: &Path) -> Option<PathBuf> {
597    let resolved = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
598    let exe = resolved.as_path();
599
600    if let Some(cellar) = exe
601        .ancestors()
602        .find(|path| path.file_name().is_some_and(|name| name == "Cellar"))
603    {
604        return cellar
605            .parent()
606            .map(|prefix| prefix.join("var").join("zeroclaw"));
607    }
608
609    let prefix = exe.parent()?.parent()?;
610    prefix
611        .join("Cellar")
612        .is_dir()
613        .then(|| prefix.join("var").join("zeroclaw"))
614}
615
616#[cfg(test)]
617mod homebrew_tests {
618    use super::*;
619
620    #[test]
621    fn homebrew_var_dir_from_exe_detects_cellar_path() {
622        let exe = PathBuf::from("/opt/homebrew/Cellar/zeroclaw/1.2.3/bin/zeroclaw");
623        let var_dir = homebrew_var_dir_from_exe(&exe);
624        assert_eq!(var_dir, Some(PathBuf::from("/opt/homebrew/var/zeroclaw")));
625    }
626
627    #[test]
628    fn homebrew_var_dir_from_exe_detects_intel_cellar_path() {
629        let exe = PathBuf::from("/usr/local/Cellar/zeroclaw/1.0.0/bin/zeroclaw");
630        let var_dir = homebrew_var_dir_from_exe(&exe);
631        assert_eq!(var_dir, Some(PathBuf::from("/usr/local/var/zeroclaw")));
632    }
633
634    #[test]
635    fn homebrew_var_dir_from_exe_ignores_non_homebrew_path() {
636        let exe = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
637        let var_dir = homebrew_var_dir_from_exe(&exe);
638        assert_eq!(var_dir, None);
639    }
640
641    #[cfg(unix)]
642    #[test]
643    fn homebrew_var_dir_from_exe_detects_opt_symlink_layout() {
644        let temp = tempfile::tempdir().expect("tempdir");
645        let prefix = temp.path().join("homebrew");
646        let cellar_bin = prefix.join("Cellar/zeroclaw/1.2.3/bin");
647        std::fs::create_dir_all(&cellar_bin).expect("create Cellar binary dir");
648        let cellar_exe = cellar_bin.join("zeroclaw");
649        std::fs::write(&cellar_exe, "").expect("create fake executable");
650
651        let opt_parent = prefix.join("opt");
652        std::fs::create_dir_all(&opt_parent).expect("create opt dir");
653        std::os::unix::fs::symlink(
654            prefix.join("Cellar/zeroclaw/1.2.3"),
655            opt_parent.join("zeroclaw"),
656        )
657        .expect("create opt symlink");
658
659        let expected_prefix = prefix
660            .canonicalize()
661            .expect("canonicalize fake Homebrew prefix");
662        let var_dir = homebrew_var_dir_from_exe(&prefix.join("opt/zeroclaw/bin/zeroclaw"));
663        assert_eq!(var_dir, Some(expected_prefix.join("var/zeroclaw")));
664    }
665}
666
667fn install_macos(config: &Config) -> Result<()> {
668    let file = macos_service_file()?;
669    if let Some(parent) = file.parent() {
670        fs::create_dir_all(parent)?;
671    }
672
673    let exe = std::env::current_exe().context("Failed to resolve current executable")?;
674
675    // When installed via Homebrew, use the Homebrew var directory for runtime
676    // data so that `brew services start zeroclaw` works out of the box.
677    let homebrew_var_dir = homebrew_var_dir_from_exe(&exe);
678    if let Some(ref var_dir) = homebrew_var_dir {
679        fs::create_dir_all(var_dir).with_context(|| {
680            format!(
681                "Failed to create Homebrew var directory: {}",
682                var_dir.display()
683            )
684        })?;
685    }
686
687    let logs_dir = if let Some(ref var_dir) = homebrew_var_dir {
688        var_dir.join("logs")
689    } else {
690        config
691            .config_path
692            .parent()
693            .map_or_else(|| PathBuf::from("."), PathBuf::from)
694            .join("logs")
695    };
696    fs::create_dir_all(&logs_dir)?;
697
698    let stdout = logs_dir.join("daemon.stdout.log");
699    let stderr = logs_dir.join("daemon.stderr.log");
700
701    let plist =
702        render_macos_launch_agent_plist(&exe, &stdout, &stderr, homebrew_var_dir.as_deref());
703
704    fs::write(&file, plist)?;
705    println!("✅ Installed launchd service: {}", file.display());
706    if let Some(ref var_dir) = homebrew_var_dir {
707        println!("   Homebrew var: {}", var_dir.display());
708    }
709    println!("   Start with: zeroclaw service start");
710    Ok(())
711}
712
713/// Renders the macOS LaunchAgent plist; path arguments are XML-escaped before interpolation,
714/// and the caller is responsible for writing the returned XML to the plist path.
715fn render_macos_launch_agent_plist(
716    exe: &Path,
717    stdout: &Path,
718    stderr: &Path,
719    homebrew_var_dir: Option<&Path>,
720) -> String {
721    // When running under Homebrew, inject ZEROCLAW_CONFIG_DIR and
722    // WorkingDirectory so the daemon finds its data in the Homebrew prefix.
723    let env_section = if let Some(var_dir) = homebrew_var_dir {
724        format!(
725            r#"  <key>EnvironmentVariables</key>
726  <dict>
727    <key>ZEROCLAW_CONFIG_DIR</key>
728    <string>{config_dir}</string>
729  </dict>
730  <key>WorkingDirectory</key>
731  <string>{working_dir}</string>
732"#,
733            config_dir = xml_escape(&var_dir.display().to_string()),
734            working_dir = xml_escape(&var_dir.display().to_string()),
735        )
736    } else {
737        String::new()
738    };
739
740    format!(
741        r#"<?xml version="1.0" encoding="UTF-8"?>
742<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
743<plist version="1.0">
744<dict>
745  <key>Label</key>
746  <string>{label}</string>
747  <key>ProgramArguments</key>
748  <array>
749    <string>{exe}</string>
750    <string>daemon</string>
751  </array>
752  <key>RunAtLoad</key>
753  <true/>
754  <key>KeepAlive</key>
755  <true/>
756{env_section}  <key>StandardOutPath</key>
757  <string>{stdout}</string>
758  <key>StandardErrorPath</key>
759  <string>{stderr}</string>
760</dict>
761</plist>
762"#,
763        label = SERVICE_LABEL,
764        exe = xml_escape(&exe.display().to_string()),
765        env_section = env_section,
766        stdout = xml_escape(&stdout.display().to_string()),
767        stderr = xml_escape(&stderr.display().to_string())
768    )
769}
770
771fn install_linux(config: &Config, init_system: InitSystem) -> Result<()> {
772    match init_system {
773        InitSystem::Systemd => install_linux_systemd(config),
774        InitSystem::Openrc => install_linux_openrc(config),
775        InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
776    }
777}
778
779fn install_linux_systemd(config: &Config) -> Result<()> {
780    let file = linux_service_file(config)?;
781    if let Some(parent) = file.parent() {
782        fs::create_dir_all(parent)?;
783    }
784
785    let exe = std::env::current_exe().context("Failed to resolve current executable")?;
786    let unit = format!(
787        "[Unit]\n\
788         Description=ZeroClaw daemon\n\
789         After=network.target\n\
790         \n\
791         [Service]\n\
792         Type=simple\n\
793         ExecStart={exe} daemon\n\
794         Restart=always\n\
795         RestartSec=3\n\
796         # Ensure HOME is set so headless browsers can create profile/cache dirs.\n\
797         Environment=HOME=%h\n\
798         # Allow inheriting DISPLAY and XDG_RUNTIME_DIR from the user session\n\
799         # so graphical/headless browsers can function correctly.\n\
800         PassEnvironment=DISPLAY XDG_RUNTIME_DIR\n\
801         \n\
802         [Install]\n\
803         WantedBy=default.target\n",
804        exe = exe.display()
805    );
806
807    fs::write(&file, unit)?;
808    let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]));
809    let _ = run_checked(Command::new("systemctl").args(["--user", "enable", "zeroclaw.service"]));
810    println!(
811        "✅ Installed systemd user service: {}",
812        file.display().to_string()
813    );
814    println!("   Start with: zeroclaw service start");
815    Ok(())
816}
817
818/// Check if the current process is running as root (Unix only)
819#[cfg(unix)]
820fn is_root() -> bool {
821    // SAFETY: `getuid()` is a simple system call that returns the real user ID of the calling
822    // process. It is always safe to call as it takes no arguments and returns a scalar value.
823    // This is a well-established pattern in Rust for getting the current user ID.
824    unsafe { libc::getuid() == 0 }
825}
826
827#[cfg(not(unix))]
828fn is_root() -> bool {
829    false
830}
831
832/// Check if the zeroclaw user exists and has expected properties.
833/// Returns Ok if user doesn't exist (OpenRC will handle creation or fail gracefully).
834/// Returns error if user exists but has unexpected properties.
835fn check_zeroclaw_user() -> Result<()> {
836    let output = Command::new("getent").args(["passwd", "zeroclaw"]).output();
837    let is_alpine = Path::new("/etc/alpine-release").exists();
838
839    let (del_cmd, add_cmd) = if is_alpine {
840        (
841            "deluser zeroclaw && delgroup zeroclaw",
842            "addgroup -S zeroclaw && adduser -S -s /sbin/nologin -H -D -G zeroclaw zeroclaw",
843        )
844    } else {
845        ("userdel zeroclaw", "useradd -r -s /sbin/nologin zeroclaw")
846    };
847
848    match output {
849        Ok(output) if output.status.success() => {
850            let passwd_entry = String::from_utf8_lossy(&output.stdout);
851            let parts: Vec<&str> = passwd_entry.split(':').collect();
852            if parts.len() >= 7 {
853                let uid = parts[2];
854                let gid = parts[3];
855                let home = parts[5];
856                let shell = parts[6];
857
858                if uid.parse::<u32>().unwrap_or(999) >= 1000 {
859                    bail!(
860                        "User 'zeroclaw' exists but has unexpected UID {} (expected system UID < 1000).\n\
861                         Recreate with: sudo {} && sudo {}",
862                        uid,
863                        del_cmd,
864                        add_cmd
865                    );
866                }
867
868                if !shell.contains("nologin") && !shell.contains("false") {
869                    bail!(
870                        "User 'zeroclaw' exists but has unexpected shell '{}'.\n\
871                         Expected nologin/false for security. Fix with: sudo {} && sudo {}",
872                        shell,
873                        del_cmd,
874                        add_cmd
875                    );
876                }
877
878                if home != "/var/lib/zeroclaw" && home != "/nonexistent" {
879                    eprintln!(
880                        "⚠️  Warning: zeroclaw user has home directory '{}' (expected /var/lib/zeroclaw or /nonexistent)",
881                        home
882                    );
883                }
884
885                let _ = gid;
886            }
887            Ok(())
888        }
889        _ => Ok(()),
890    }
891}
892
893fn ensure_zeroclaw_user() -> Result<()> {
894    let output = Command::new("getent").args(["passwd", "zeroclaw"]).output();
895    if let Ok(output) = output
896        && output.status.success()
897    {
898        return check_zeroclaw_user();
899    }
900
901    let is_alpine = Path::new("/etc/alpine-release").exists();
902
903    if is_alpine {
904        let group_output = Command::new("getent").args(["group", "zeroclaw"]).output();
905        let group_exists = group_output.map(|o| o.status.success()).unwrap_or(false);
906
907        if !group_exists {
908            let output = Command::new("addgroup")
909                .args(["-S", "zeroclaw"])
910                .output()
911                .context("Failed to create zeroclaw group")?;
912
913            if !output.status.success() {
914                let stderr = String::from_utf8_lossy(&output.stderr);
915                bail!("Failed to create zeroclaw group: {}", stderr.trim());
916            }
917            println!("✅ Created system group: zeroclaw");
918        }
919
920        let output = Command::new("adduser")
921            .args([
922                "-S",
923                "-s",
924                "/sbin/nologin",
925                "-H",
926                "-D",
927                "-G",
928                "zeroclaw",
929                "zeroclaw",
930            ])
931            .output()
932            .context("Failed to create zeroclaw user")?;
933
934        if !output.status.success() {
935            let stderr = String::from_utf8_lossy(&output.stderr);
936            bail!("Failed to create zeroclaw user: {}", stderr.trim());
937        }
938    } else {
939        let output = Command::new("useradd")
940            .args(["-r", "-s", "/sbin/nologin", "zeroclaw"])
941            .output()
942            .context("Failed to create zeroclaw user")?;
943
944        if !output.status.success() {
945            let stderr = String::from_utf8_lossy(&output.stderr);
946            bail!("Failed to create zeroclaw user: {}", stderr.trim());
947        }
948    }
949
950    println!("✅ Created system user: zeroclaw");
951    Ok(())
952}
953
954/// Change ownership of a path to zeroclaw:zeroclaw
955#[cfg(unix)]
956fn chown_to_zeroclaw(path: &Path) -> Result<()> {
957    let output = Command::new("chown")
958        .args(["zeroclaw:zeroclaw", &path.to_string_lossy()])
959        .output()
960        .context("Failed to run chown")?;
961
962    if !output.status.success() {
963        let stderr = String::from_utf8_lossy(&output.stderr);
964        bail!(
965            "Failed to change ownership of {} to zeroclaw:zeroclaw: {}",
966            path.display().to_string(),
967            stderr.trim(),
968        );
969    }
970    Ok(())
971}
972
973#[cfg(not(unix))]
974fn chown_to_zeroclaw(_path: &Path) -> Result<()> {
975    Ok(())
976}
977
978#[cfg(unix)]
979fn chown_recursive_to_zeroclaw(path: &Path) -> Result<()> {
980    let output = Command::new("chown")
981        .args(["-R", "zeroclaw:zeroclaw", &path.to_string_lossy()])
982        .output()
983        .context("Failed to run recursive chown")?;
984
985    if !output.status.success() {
986        let stderr = String::from_utf8_lossy(&output.stderr);
987        bail!(
988            "Failed to recursively change ownership of {} to zeroclaw:zeroclaw: {}",
989            path.display().to_string(),
990            stderr.trim(),
991        );
992    }
993
994    Ok(())
995}
996
997#[cfg(not(unix))]
998fn chown_recursive_to_zeroclaw(_path: &Path) -> Result<()> {
999    Ok(())
1000}
1001
1002fn copy_dir_recursive(source: &Path, target: &Path) -> Result<()> {
1003    fs::create_dir_all(target).with_context(|| {
1004        format!(
1005            "Failed to create directory {}",
1006            target.display().to_string()
1007        )
1008    })?;
1009
1010    for entry in fs::read_dir(source)
1011        .with_context(|| format!("Failed to read directory {}", source.display().to_string()))?
1012    {
1013        let entry = entry?;
1014        let source_path = entry.path();
1015        let target_path = target.join(entry.file_name());
1016        let file_type = entry
1017            .file_type()
1018            .with_context(|| format!("Failed to inspect {}", source_path.display().to_string()))?;
1019
1020        if file_type.is_dir() {
1021            copy_dir_recursive(&source_path, &target_path)?;
1022        } else if file_type.is_file() {
1023            if target_path.exists() {
1024                continue;
1025            }
1026            fs::copy(&source_path, &target_path).with_context(|| {
1027                format!(
1028                    "Failed to copy file {} -> {}",
1029                    source_path.display().to_string(),
1030                    target_path.display()
1031                )
1032            })?;
1033        }
1034    }
1035
1036    Ok(())
1037}
1038
1039fn resolve_invoking_user_config_dir() -> Option<PathBuf> {
1040    let sudo_user = std::env::var("SUDO_USER")
1041        .ok()
1042        .map(|value| value.trim().to_string())
1043        .filter(|value| !value.is_empty() && value != "root");
1044
1045    if let Some(user) = sudo_user
1046        && let Ok(output) = Command::new("getent").args(["passwd", &user]).output()
1047        && output.status.success()
1048    {
1049        let entry = String::from_utf8_lossy(&output.stdout);
1050        let fields: Vec<&str> = entry.trim().split(':').collect();
1051        if fields.len() >= 6 {
1052            return Some(PathBuf::from(fields[5]).join(".zeroclaw"));
1053        }
1054    }
1055
1056    std::env::var("HOME")
1057        .ok()
1058        .map(PathBuf::from)
1059        .map(|home| home.join(".zeroclaw"))
1060}
1061
1062fn migrate_openrc_runtime_state_if_needed(config_dir: &Path) -> Result<()> {
1063    let target_config = config_dir.join("config.toml");
1064    if target_config.exists() {
1065        println!(
1066            "✅ Reusing existing OpenRC config at {}",
1067            target_config.display()
1068        );
1069        return Ok(());
1070    }
1071
1072    let Some(source_dir) = resolve_invoking_user_config_dir() else {
1073        return Ok(());
1074    };
1075
1076    let source_config = source_dir.join("config.toml");
1077    if !source_config.exists() {
1078        return Ok(());
1079    }
1080
1081    copy_dir_recursive(&source_dir, config_dir)?;
1082    println!(
1083        "✅ Migrated runtime state from {} to {}",
1084        source_dir.display().to_string(),
1085        config_dir.display()
1086    );
1087    Ok(())
1088}
1089
1090#[cfg(unix)]
1091fn shell_single_quote(raw: &str) -> String {
1092    format!("'{}'", raw.replace('\'', "'\"'\"'"))
1093}
1094
1095#[cfg(unix)]
1096fn build_openrc_writability_probe_command(path: &Path, has_runuser: bool) -> (String, Vec<String>) {
1097    let probe = format!("test -w {}", shell_single_quote(&path.to_string_lossy()));
1098    if has_runuser {
1099        (
1100            "runuser".to_string(),
1101            vec![
1102                "-u".to_string(),
1103                "zeroclaw".to_string(),
1104                "--".to_string(),
1105                "sh".to_string(),
1106                "-c".to_string(),
1107                probe,
1108            ],
1109        )
1110    } else {
1111        (
1112            "su".to_string(),
1113            vec![
1114                "-s".to_string(),
1115                "/bin/sh".to_string(),
1116                "-c".to_string(),
1117                probe,
1118                "zeroclaw".to_string(),
1119            ],
1120        )
1121    }
1122}
1123
1124#[cfg(unix)]
1125fn ensure_openrc_runtime_path_writable(path: &Path) -> Result<()> {
1126    let has_runuser = which::which("runuser").is_ok();
1127    let (program, args) = build_openrc_writability_probe_command(path, has_runuser);
1128    let output = Command::new(&program)
1129        .args(args.iter().map(String::as_str))
1130        .output()
1131        .with_context(|| {
1132            format!(
1133                "Failed to verify OpenRC runtime write access for {}",
1134                path.display()
1135            )
1136        })?;
1137
1138    if !output.status.success() {
1139        let stderr = String::from_utf8_lossy(&output.stderr);
1140        let details = if stderr.trim().is_empty() {
1141            "write-access probe failed"
1142        } else {
1143            stderr.trim()
1144        };
1145        bail!(
1146            "OpenRC runtime user 'zeroclaw' cannot write {} ({details}). \
1147             Re-run `sudo zeroclaw service install` and ensure ownership is zeroclaw:zeroclaw.",
1148            path.display().to_string(),
1149        );
1150    }
1151
1152    Ok(())
1153}
1154
1155#[cfg(unix)]
1156fn ensure_openrc_runtime_dirs_writable(
1157    config_dir: &Path,
1158    workspace_dir: &Path,
1159    log_dir: &Path,
1160) -> Result<()> {
1161    for path in [config_dir, workspace_dir, log_dir] {
1162        ensure_openrc_runtime_path_writable(path)?;
1163    }
1164    Ok(())
1165}
1166
1167#[cfg(not(unix))]
1168fn ensure_openrc_runtime_dirs_writable(
1169    _config_dir: &Path,
1170    _workspace_dir: &Path,
1171    _log_dir: &Path,
1172) -> Result<()> {
1173    Ok(())
1174}
1175
1176/// Warn if the binary path is in a user home directory
1177fn warn_if_binary_in_home(exe_path: &Path) {
1178    let path_str = exe_path.to_string_lossy();
1179    if path_str.contains("/home/") || path_str.contains(".cargo/bin") {
1180        eprintln!(
1181            "⚠️  Warning: Binary path '{}' appears to be in a user home directory.\n\
1182             For system-wide OpenRC service, consider installing to /usr/local/bin:\n\
1183             sudo cp '{}' /usr/local/bin/zeroclaw",
1184            exe_path.display().to_string(),
1185            exe_path.display()
1186        );
1187    }
1188}
1189
1190/// Generate OpenRC init script content (pure function for testability)
1191fn generate_openrc_script(exe_path: &Path, config_dir: &Path) -> String {
1192    format!(
1193        r#"#!/sbin/openrc-run
1194
1195name="zeroclaw"
1196description="ZeroClaw daemon"
1197
1198command="{exe}"
1199command_args="--config-dir {config_dir} daemon"
1200command_background="yes"
1201command_user="zeroclaw:zeroclaw"
1202pidfile="/run/${{RC_SVCNAME}}.pid"
1203umask 027
1204output_log="/var/log/zeroclaw/access.log"
1205error_log="/var/log/zeroclaw/error.log"
1206
1207# Provide HOME so headless browsers can create profile/cache directories.
1208# Without this, Chromium/Firefox fail with sandbox or profile errors.
1209export HOME="/var/lib/zeroclaw"
1210
1211depend() {{
1212    need net
1213    after firewall
1214}}
1215
1216start_pre() {{
1217    checkpath --directory --owner zeroclaw:zeroclaw --mode 0750 /var/lib/zeroclaw
1218}}
1219"#,
1220        exe = exe_path.display().to_string(),
1221        config_dir = config_dir.display().to_string(),
1222    )
1223}
1224
1225fn resolve_openrc_executable() -> Result<PathBuf> {
1226    let preferred = Path::new("/usr/local/bin/zeroclaw");
1227    if preferred.exists() {
1228        return Ok(preferred.to_path_buf());
1229    }
1230
1231    let exe = std::env::current_exe().context("Failed to resolve current executable")?;
1232    Ok(exe)
1233}
1234
1235fn install_linux_openrc(config: &Config) -> Result<()> {
1236    if !is_root() {
1237        bail!(
1238            "OpenRC service installation requires root privileges.\n\
1239             Please run with sudo: sudo zeroclaw service install"
1240        );
1241    }
1242
1243    ensure_zeroclaw_user()?;
1244
1245    let exe = resolve_openrc_executable()?;
1246    warn_if_binary_in_home(&exe);
1247
1248    let config_dir = Path::new("/etc/zeroclaw");
1249    let workspace_dir = config_dir.join("workspace");
1250    let log_dir = Path::new("/var/log/zeroclaw");
1251
1252    if !config_dir.exists() {
1253        fs::create_dir_all(config_dir)
1254            .with_context(|| format!("Failed to create {}", config_dir.display().to_string()))?;
1255        #[cfg(unix)]
1256        {
1257            use std::os::unix::fs::PermissionsExt;
1258            fs::set_permissions(config_dir, fs::Permissions::from_mode(0o755)).with_context(
1259                || {
1260                    format!(
1261                        "Failed to set permissions on {}",
1262                        config_dir.display().to_string()
1263                    )
1264                },
1265            )?;
1266        }
1267        println!("✅ Created directory: {}", config_dir.display().to_string());
1268    }
1269
1270    migrate_openrc_runtime_state_if_needed(config_dir)?;
1271
1272    if !workspace_dir.exists() {
1273        fs::create_dir_all(&workspace_dir)
1274            .with_context(|| format!("Failed to create {}", workspace_dir.display().to_string()))?;
1275        #[cfg(unix)]
1276        {
1277            use std::os::unix::fs::PermissionsExt;
1278            fs::set_permissions(&workspace_dir, fs::Permissions::from_mode(0o750)).with_context(
1279                || {
1280                    format!(
1281                        "Failed to set permissions on {}",
1282                        workspace_dir.display().to_string()
1283                    )
1284                },
1285            )?;
1286        }
1287        chown_to_zeroclaw(&workspace_dir)?;
1288        println!(
1289            "✅ Created directory: {} (owned by zeroclaw:zeroclaw)",
1290            workspace_dir.display()
1291        );
1292    }
1293
1294    #[cfg(unix)]
1295    {
1296        use std::os::unix::fs::PermissionsExt;
1297        fs::set_permissions(&workspace_dir, fs::Permissions::from_mode(0o750)).with_context(
1298            || {
1299                format!(
1300                    "Failed to set permissions on {}",
1301                    workspace_dir.display().to_string()
1302                )
1303            },
1304        )?;
1305    }
1306
1307    #[cfg(unix)]
1308    {
1309        use std::os::unix::fs::PermissionsExt;
1310        fs::set_permissions(config_dir, fs::Permissions::from_mode(0o755)).with_context(|| {
1311            format!(
1312                "Failed to set permissions on {}",
1313                config_dir.display().to_string()
1314            )
1315        })?;
1316        let config_path = config_dir.join("config.toml");
1317        if config_path.exists() {
1318            fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)).with_context(
1319                || {
1320                    format!(
1321                        "Failed to set permissions on {}",
1322                        config_path.display().to_string()
1323                    )
1324                },
1325            )?;
1326        }
1327        let secret_key_path = config_dir.join(".secret_key");
1328        if secret_key_path.exists() {
1329            fs::set_permissions(&secret_key_path, fs::Permissions::from_mode(0o600)).with_context(
1330                || {
1331                    format!(
1332                        "Failed to set permissions on {}",
1333                        secret_key_path.display().to_string()
1334                    )
1335                },
1336            )?;
1337        }
1338    }
1339
1340    chown_recursive_to_zeroclaw(config_dir)?;
1341
1342    let created_log_dir = !log_dir.exists();
1343    if created_log_dir {
1344        fs::create_dir_all(log_dir)
1345            .with_context(|| format!("Failed to create {}", log_dir.display().to_string()))?;
1346        #[cfg(unix)]
1347        {
1348            use std::os::unix::fs::PermissionsExt;
1349            fs::set_permissions(log_dir, fs::Permissions::from_mode(0o750)).with_context(|| {
1350                format!(
1351                    "Failed to set permissions on {}",
1352                    log_dir.display().to_string()
1353                )
1354            })?;
1355        }
1356    }
1357
1358    chown_to_zeroclaw(log_dir)?;
1359
1360    ensure_openrc_runtime_dirs_writable(config_dir, &workspace_dir, log_dir)?;
1361
1362    if created_log_dir {
1363        println!(
1364            "✅ Created directory: {} (owned by zeroclaw:zeroclaw)",
1365            log_dir.display()
1366        );
1367    }
1368
1369    let init_script = generate_openrc_script(&exe, config_dir);
1370    let init_path = Path::new("/etc/init.d/zeroclaw");
1371    fs::write(init_path, init_script)
1372        .with_context(|| format!("Failed to write {}", init_path.display().to_string()))?;
1373
1374    #[cfg(unix)]
1375    {
1376        use std::os::unix::fs::PermissionsExt;
1377        fs::set_permissions(init_path, fs::Permissions::from_mode(0o755)).with_context(|| {
1378            format!(
1379                "Failed to set permissions on {}",
1380                init_path.display().to_string()
1381            )
1382        })?;
1383    }
1384
1385    run_checked(Command::new("rc-update").args(["add", "zeroclaw", "default"]))?;
1386    println!("✅ Installed OpenRC service: /etc/init.d/zeroclaw");
1387    println!("   Config path: /etc/zeroclaw/config.toml");
1388    println!("   Start with: sudo zeroclaw service start");
1389    let _ = config;
1390    Ok(())
1391}
1392
1393fn install_windows(config: &Config) -> Result<()> {
1394    let exe = std::env::current_exe().context("Failed to resolve current executable")?;
1395    let logs_dir = config
1396        .config_path
1397        .parent()
1398        .map_or_else(|| PathBuf::from("."), PathBuf::from)
1399        .join("logs");
1400    fs::create_dir_all(&logs_dir)?;
1401
1402    // Create a wrapper script that redirects output to log files
1403    let wrapper = logs_dir.join("zeroclaw-daemon.cmd");
1404    let stdout_log = logs_dir.join("daemon.stdout.log");
1405    let stderr_log = logs_dir.join("daemon.stderr.log");
1406
1407    let wrapper_content = format!(
1408        "@echo off\r\n\"{}\" daemon >>\"{}\" 2>>\"{}\"",
1409        exe.display().to_string(),
1410        stdout_log.display().to_string(),
1411        stderr_log.display()
1412    );
1413    fs::write(&wrapper, &wrapper_content)?;
1414
1415    let task_name = windows_task_name();
1416
1417    // Remove any existing task first (ignore errors if it doesn't exist)
1418    let _ = Command::new("schtasks")
1419        .args(["/Delete", "/TN", task_name, "/F"])
1420        .output();
1421
1422    run_checked(Command::new("schtasks").args([
1423        "/Create",
1424        "/TN",
1425        task_name,
1426        "/SC",
1427        "ONLOGON",
1428        "/TR",
1429        &format!("\"{}\"", wrapper.display().to_string()),
1430        "/RL",
1431        "HIGHEST",
1432        "/F",
1433    ]))?;
1434
1435    println!("✅ Installed Windows scheduled task: {}", task_name);
1436    println!("   Wrapper: {}", wrapper.display().to_string());
1437    println!("   Logs: {}", logs_dir.display().to_string());
1438    println!("   Start with: zeroclaw service start");
1439    Ok(())
1440}
1441
1442fn macos_service_file() -> Result<PathBuf> {
1443    let home = directories::UserDirs::new()
1444        .map(|u| u.home_dir().to_path_buf())
1445        .context("Could not find home directory")?;
1446    Ok(home
1447        .join("Library")
1448        .join("LaunchAgents")
1449        .join(format!("{SERVICE_LABEL}.plist")))
1450}
1451
1452fn linux_service_file(config: &Config) -> Result<PathBuf> {
1453    let home = directories::UserDirs::new()
1454        .map(|u| u.home_dir().to_path_buf())
1455        .context("Could not find home directory")?;
1456    let _ = config;
1457    Ok(home
1458        .join(".config")
1459        .join("systemd")
1460        .join("user")
1461        .join("zeroclaw.service"))
1462}
1463
1464fn run_checked(command: &mut Command) -> Result<()> {
1465    let output = command.output().context("Failed to spawn command")?;
1466    if !output.status.success() {
1467        let stderr = String::from_utf8_lossy(&output.stderr);
1468        anyhow::bail!("Command failed: {}", stderr.trim());
1469    }
1470    Ok(())
1471}
1472
1473pub fn run_capture(command: &mut Command) -> Result<String> {
1474    let output = command.output().context("Failed to spawn command")?;
1475    let mut text = String::from_utf8_lossy(&output.stdout).to_string();
1476    if text.trim().is_empty() {
1477        text = String::from_utf8_lossy(&output.stderr).to_string();
1478    }
1479    Ok(text)
1480}
1481
1482pub fn xml_escape(raw: &str) -> String {
1483    raw.replace('&', "&amp;")
1484        .replace('<', "&lt;")
1485        .replace('>', "&gt;")
1486        .replace('"', "&quot;")
1487        .replace('\'', "&apos;")
1488}
1489
1490// Plain `#[cfg(test)]` is intentional: these pure renderer tests have no
1491// integration dependencies and should run in every zeroclaw-runtime test build.
1492#[cfg(test)]
1493mod macos_plist_tests {
1494    use super::*;
1495
1496    #[test]
1497    fn macos_plist_renderer_uses_plain_xml_quotes() {
1498        let plist = render_macos_launch_agent_plist(
1499            Path::new("/opt/homebrew/bin/zeroclaw"),
1500            Path::new("/opt/homebrew/var/zeroclaw/logs/daemon.stdout.log"),
1501            Path::new("/opt/homebrew/var/zeroclaw/logs/daemon.stderr.log"),
1502            Some(Path::new("/opt/homebrew/var/zeroclaw")),
1503        );
1504
1505        assert!(!plist.contains(r#"\""#));
1506        assert!(plist.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
1507        assert!(plist.contains(
1508            r#"<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">"#
1509        ));
1510        assert!(plist.contains(r#"<plist version="1.0">"#));
1511        assert!(plist.contains("<key>EnvironmentVariables</key>"));
1512    }
1513
1514    #[test]
1515    fn macos_plist_renderer_escapes_paths_and_omits_homebrew_section_when_absent() {
1516        let plist = render_macos_launch_agent_plist(
1517            Path::new("/tmp/Zero<&>\"'Claw/bin/zeroclaw"),
1518            Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stdout.log"),
1519            Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stderr.log"),
1520            None,
1521        );
1522
1523        assert!(plist.contains("/tmp/Zero&lt;&amp;&gt;&quot;&apos;Claw/bin/zeroclaw"));
1524        assert!(plist.contains("/tmp/Zero&lt;&amp;&gt;&quot;&apos;Claw/logs/daemon.stdout.log"));
1525        assert!(plist.contains("/tmp/Zero&lt;&amp;&gt;&quot;&apos;Claw/logs/daemon.stderr.log"));
1526        assert!(!plist.contains("<key>EnvironmentVariables</key>"));
1527        assert!(!plist.contains("<key>WorkingDirectory</key>"));
1528    }
1529
1530    #[cfg(target_os = "macos")]
1531    #[test]
1532    fn macos_plist_renderer_emits_plutil_parseable_xml() {
1533        let plist = render_macos_launch_agent_plist(
1534            Path::new("/tmp/Zero<&>\"'Claw/bin/zeroclaw"),
1535            Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stdout.log"),
1536            Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stderr.log"),
1537            Some(Path::new("/tmp/Zero<&>\"'Claw/var/zeroclaw")),
1538        );
1539
1540        let file = std::env::temp_dir().join(format!(
1541            "zeroclaw-launch-agent-plist-{}.plist",
1542            std::process::id()
1543        ));
1544        fs::write(&file, plist).expect("write plist fixture");
1545
1546        let output = Command::new("plutil")
1547            .arg("-lint")
1548            .arg(&file)
1549            .output()
1550            .expect("run plutil");
1551        let _ = fs::remove_file(&file);
1552
1553        assert!(
1554            output.status.success(),
1555            "plutil failed\nstdout:\n{}\nstderr:\n{}",
1556            String::from_utf8_lossy(&output.stdout),
1557            String::from_utf8_lossy(&output.stderr)
1558        );
1559    }
1560}
1561
1562#[cfg(all(test, zeroclaw_root_crate))]
1563mod tests {
1564    use super::*;
1565
1566    #[test]
1567    fn xml_escape_escapes_reserved_chars() {
1568        let escaped = xml_escape("<&>\"' and text");
1569        assert_eq!(escaped, "&lt;&amp;&gt;&quot;&apos; and text");
1570    }
1571
1572    #[cfg(not(target_os = "windows"))]
1573    #[test]
1574    fn run_capture_reads_stdout() {
1575        let out = run_capture(Command::new("sh").args(["-c", "echo hello"]))
1576            .expect("stdout capture should succeed");
1577        assert_eq!(out.trim(), "hello");
1578    }
1579
1580    #[cfg(not(target_os = "windows"))]
1581    #[test]
1582    fn run_capture_falls_back_to_stderr() {
1583        let out = run_capture(Command::new("sh").args(["-c", "echo warn 1>&2"]))
1584            .expect("stderr capture should succeed");
1585        assert_eq!(out.trim(), "warn");
1586    }
1587
1588    #[cfg(not(target_os = "windows"))]
1589    #[test]
1590    fn run_checked_errors_on_non_zero_status() {
1591        let err = run_checked(Command::new("sh").args(["-c", "exit 17"]))
1592            .expect_err("non-zero exit should error");
1593        assert!(err.to_string().contains("Command failed"));
1594    }
1595
1596    #[cfg(not(target_os = "windows"))]
1597    #[test]
1598    fn linux_service_file_has_expected_suffix() {
1599        let file = linux_service_file(&Config::default()).unwrap();
1600        let path = file.to_string_lossy();
1601        assert!(path.ends_with(".config/systemd/user/zeroclaw.service"));
1602    }
1603
1604    #[test]
1605    fn windows_task_name_is_constant() {
1606        assert_eq!(windows_task_name(), "ZeroClaw Daemon");
1607    }
1608
1609    #[cfg(target_os = "windows")]
1610    #[test]
1611    fn run_capture_reads_stdout_windows() {
1612        let out = run_capture(Command::new("cmd").args(["/C", "echo hello"]))
1613            .expect("stdout capture should succeed");
1614        assert_eq!(out.trim(), "hello");
1615    }
1616
1617    #[cfg(target_os = "windows")]
1618    #[test]
1619    fn run_checked_errors_on_non_zero_status_windows() {
1620        let err = run_checked(Command::new("cmd").args(["/C", "exit /b 17"]))
1621            .expect_err("non-zero exit should error");
1622        assert!(err.to_string().contains("Command failed"));
1623    }
1624
1625    #[test]
1626    fn init_system_from_str_parses_valid_values() {
1627        assert_eq!("auto".parse::<InitSystem>().unwrap(), InitSystem::Auto);
1628        assert_eq!("AUTO".parse::<InitSystem>().unwrap(), InitSystem::Auto);
1629        assert_eq!(
1630            "systemd".parse::<InitSystem>().unwrap(),
1631            InitSystem::Systemd
1632        );
1633        assert_eq!(
1634            "SYSTEMD".parse::<InitSystem>().unwrap(),
1635            InitSystem::Systemd
1636        );
1637        assert_eq!("openrc".parse::<InitSystem>().unwrap(), InitSystem::Openrc);
1638        assert_eq!("OPENRC".parse::<InitSystem>().unwrap(), InitSystem::Openrc);
1639    }
1640
1641    #[test]
1642    fn init_system_from_str_rejects_unknown() {
1643        let err = "unknown"
1644            .parse::<InitSystem>()
1645            .expect_err("should reject unknown");
1646        assert!(err.to_string().contains("Unknown init system"));
1647        assert!(err.to_string().contains("Supported: auto, systemd, openrc"));
1648    }
1649
1650    #[test]
1651    fn init_system_default_is_auto() {
1652        assert_eq!(InitSystem::default(), InitSystem::Auto);
1653    }
1654
1655    #[cfg(unix)]
1656    #[test]
1657    fn is_root_matches_system_uid() {
1658        // SAFETY: `getuid()` is a simple system call that returns the real user ID of the calling
1659        // process. It is always safe to call as it takes no arguments and returns a scalar value.
1660        // This test verifies our `is_root()` wrapper returns the same result as the raw syscall.
1661        assert_eq!(is_root(), unsafe { libc::getuid() == 0 });
1662    }
1663
1664    #[test]
1665    fn generate_openrc_script_contains_required_directives() {
1666        use std::path::PathBuf;
1667
1668        let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1669        let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1670
1671        assert!(script.starts_with("#!/sbin/openrc-run"));
1672        assert!(script.contains("name=\"zeroclaw\""));
1673        assert!(script.contains("description=\"ZeroClaw daemon\""));
1674        assert!(script.contains("command=\"/usr/local/bin/zeroclaw\""));
1675        assert!(script.contains("command_args=\"--config-dir /etc/zeroclaw daemon\""));
1676        assert!(!script.contains("env ZEROCLAW_CONFIG_DIR"));
1677        assert!(!script.contains("env ZEROCLAW_WORKSPACE"));
1678        assert!(script.contains("command_background=\"yes\""));
1679        assert!(script.contains("command_user=\"zeroclaw:zeroclaw\""));
1680        assert!(script.contains("pidfile=\"/run/${RC_SVCNAME}.pid\""));
1681        assert!(script.contains("umask 027"));
1682        assert!(script.contains("output_log=\"/var/log/zeroclaw/access.log\""));
1683        assert!(script.contains("error_log=\"/var/log/zeroclaw/error.log\""));
1684        assert!(script.contains("depend()"));
1685        assert!(script.contains("need net"));
1686        assert!(script.contains("after firewall"));
1687    }
1688
1689    #[test]
1690    fn generate_openrc_script_sets_home_for_browser() {
1691        use std::path::PathBuf;
1692
1693        let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1694        let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1695
1696        assert!(
1697            script.contains("export HOME=\"/var/lib/zeroclaw\""),
1698            "OpenRC script must set HOME for headless browser support"
1699        );
1700    }
1701
1702    #[test]
1703    fn generate_openrc_script_creates_home_directory() {
1704        use std::path::PathBuf;
1705
1706        let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1707        let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1708
1709        assert!(
1710            script.contains("start_pre()"),
1711            "OpenRC script must have start_pre to create HOME dir"
1712        );
1713        assert!(
1714            script.contains("checkpath --directory --owner zeroclaw:zeroclaw"),
1715            "start_pre must ensure /var/lib/zeroclaw exists with correct ownership"
1716        );
1717    }
1718
1719    #[test]
1720    fn systemd_unit_contains_home_and_pass_environment() {
1721        let unit = "[Unit]\n\
1722             Description=ZeroClaw daemon\n\
1723             After=network.target\n\
1724             \n\
1725             [Service]\n\
1726             Type=simple\n\
1727             ExecStart=/usr/local/bin/zeroclaw daemon\n\
1728             Restart=always\n\
1729             RestartSec=3\n\
1730             # Ensure HOME is set so headless browsers can create profile/cache dirs.\n\
1731             Environment=HOME=%h\n\
1732             # Allow inheriting DISPLAY and XDG_RUNTIME_DIR from the user session\n\
1733             # so graphical/headless browsers can function correctly.\n\
1734             PassEnvironment=DISPLAY XDG_RUNTIME_DIR\n\
1735             \n\
1736             [Install]\n\
1737             WantedBy=default.target\n"
1738            .to_string();
1739
1740        assert!(
1741            unit.contains("Environment=HOME=%h"),
1742            "systemd unit must set HOME for headless browser support"
1743        );
1744        assert!(
1745            unit.contains("PassEnvironment=DISPLAY XDG_RUNTIME_DIR"),
1746            "systemd unit must pass through display/runtime env vars"
1747        );
1748    }
1749
1750    #[test]
1751    fn warn_if_binary_in_home_detects_home_path() {
1752        use std::path::PathBuf;
1753
1754        let home_path = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
1755        assert!(home_path.to_string_lossy().contains("/home/"));
1756        assert!(home_path.to_string_lossy().contains(".cargo/bin"));
1757
1758        let cargo_path = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
1759        assert!(cargo_path.to_string_lossy().contains(".cargo/bin"));
1760
1761        let system_path = PathBuf::from("/usr/local/bin/zeroclaw");
1762        assert!(!system_path.to_string_lossy().contains("/home/"));
1763        assert!(!system_path.to_string_lossy().contains(".cargo/bin"));
1764    }
1765
1766    #[cfg(unix)]
1767    #[test]
1768    fn shell_single_quote_escapes_single_quotes() {
1769        assert_eq!(
1770            shell_single_quote("/tmp/weird'path"),
1771            "'/tmp/weird'\"'\"'path'"
1772        );
1773    }
1774
1775    #[cfg(unix)]
1776    #[test]
1777    fn openrc_writability_probe_prefers_runuser_when_available() {
1778        let (program, args) =
1779            build_openrc_writability_probe_command(Path::new("/etc/zeroclaw"), true);
1780        assert_eq!(program, "runuser");
1781        assert_eq!(
1782            args,
1783            vec![
1784                "-u".to_string(),
1785                "zeroclaw".to_string(),
1786                "--".to_string(),
1787                "sh".to_string(),
1788                "-c".to_string(),
1789                "test -w '/etc/zeroclaw'".to_string()
1790            ]
1791        );
1792    }
1793
1794    #[cfg(unix)]
1795    #[test]
1796    fn openrc_writability_probe_falls_back_to_su() {
1797        let (program, args) =
1798            build_openrc_writability_probe_command(Path::new("/etc/zeroclaw/workspace"), false);
1799        assert_eq!(program, "su");
1800        assert_eq!(
1801            args,
1802            vec![
1803                "-s".to_string(),
1804                "/bin/sh".to_string(),
1805                "-c".to_string(),
1806                "test -w '/etc/zeroclaw/workspace'".to_string(),
1807                "zeroclaw".to_string()
1808            ]
1809        );
1810    }
1811
1812    #[cfg(not(target_os = "windows"))]
1813    #[test]
1814    fn tail_file_errors_on_missing_file() {
1815        let missing = Path::new("/tmp/zeroclaw-test-nonexistent-log-file.log");
1816        let result = tail_file(missing, 10, false);
1817        assert!(result.is_err(), "tail on missing file should fail");
1818    }
1819
1820    #[cfg(not(target_os = "windows"))]
1821    #[test]
1822    fn tail_file_reads_existing_file() {
1823        let dir = tempfile::tempdir().expect("failed to create temp dir");
1824        let log = dir.path().join("test-tail.log");
1825        fs::write(&log, "line1\nline2\nline3\nline4\nline5\n").unwrap();
1826        // tail should succeed on existing file
1827        let result = tail_file(&log, 3, false);
1828        assert!(result.is_ok(), "tail on existing file should succeed");
1829    }
1830
1831    #[test]
1832    fn logs_variant_is_recognized() {
1833        // Ensure the Logs variant can be constructed and matched
1834        let cmd = crate::ServiceCommands::Logs {
1835            lines: 25,
1836            follow: true,
1837        };
1838        match &cmd {
1839            crate::ServiceCommands::Logs { lines, follow } => {
1840                assert_eq!(*lines, 25);
1841                assert!(*follow);
1842            }
1843            _ => panic!("Expected Logs variant"),
1844        }
1845    }
1846}