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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum InitSystem {
14 #[default]
16 Auto,
17 Systemd,
19 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 #[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#[cfg(target_os = "linux")]
68fn detect_init_system() -> Result<InitSystem> {
69 if Path::new("/run/systemd/system").exists() {
71 return Ok(InitSystem::Systemd);
72 }
73
74 if Path::new("/run/openrc").exists() {
76 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
92pub 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 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 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 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 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 let log_file = Path::new("/var/log/zeroclaw/error.log");
431 if !log_file.exists() {
432 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 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
503fn 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 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
590pub 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 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
713fn render_macos_launch_agent_plist(
716 exe: &Path,
717 stdout: &Path,
718 stderr: &Path,
719 homebrew_var_dir: Option<&Path>,
720) -> String {
721 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#[cfg(unix)]
820fn is_root() -> bool {
821 unsafe { libc::getuid() == 0 }
825}
826
827#[cfg(not(unix))]
828fn is_root() -> bool {
829 false
830}
831
832fn 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#[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
1176fn 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
1190fn 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 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 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('&', "&")
1484 .replace('<', "<")
1485 .replace('>', ">")
1486 .replace('"', """)
1487 .replace('\'', "'")
1488}
1489
1490#[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<&>"'Claw/bin/zeroclaw"));
1524 assert!(plist.contains("/tmp/Zero<&>"'Claw/logs/daemon.stdout.log"));
1525 assert!(plist.contains("/tmp/Zero<&>"'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, "<&>"' 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 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 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 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}