1mod render;
6
7use std::collections::BTreeMap;
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::time::Duration;
13
14use crate::node_log;
15use crate::windows_process::{
16 free_console, hide_console, hide_console_detached, ignore_console_close_events,
17};
18
19pub use render::{
20 render_launchd_plist, render_schtasks_xml, render_systemd_unit, LaunchPlist, SchtasksXml,
21 SystemdUnit,
22};
23
24const ENV_PREFIXES: &[&str] = &["GATE4AGENT_", "HATCHERY_", "RUST_LOG", "MAIL4AGENT_"];
28
29#[derive(Clone, Debug)]
30pub struct ServiceSpec {
31 pub system: bool,
32 pub exe: PathBuf,
33 pub args: Vec<String>,
34 pub env: Vec<(String, String)>,
35 pub enroll_state: Option<PathBuf>,
36}
37
38#[derive(Clone, Debug)]
39pub struct ServiceStatus {
40 pub installed: bool,
41 pub running: bool,
42 pub scope: &'static str,
43 pub unit_name: String,
44 pub detail: String,
45 pub node_health: String,
47}
48
49impl ServiceSpec {
50 pub fn from_install_args(system: bool, node_args: &[String]) -> Result<Self, String> {
51 let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
52 let enroll_state = extract_enroll_state(node_args);
53 let mut env = capture_env();
54 if let Some(bind) = extract_m4a_bind(node_args) {
55 if !env.iter().any(|(k, _)| k == crate::kit::COURIER_BIND_ENV) {
56 env.push((crate::kit::COURIER_BIND_ENV.to_owned(), bind));
57 }
58 }
59 let spec = Self {
60 system,
61 exe,
62 args: node_args.to_vec(),
63 env,
64 enroll_state,
65 };
66 let _ = write_sidecar_env(node_args, &spec.env);
67 Ok(spec)
68 }
69
70 pub fn unit_name(&self) -> String {
71 match &self.enroll_state {
72 Some(p) => {
73 let key = p.to_string_lossy();
74 let digest = short_digest(&key);
75 format!("gate4agent-node-{digest}")
76 }
77 None => "gate4agent-node".to_owned(),
78 }
79 }
80
81 pub fn launchd_label(&self) -> String {
82 format!("com.gate4agent.node.{}", self.unit_name())
83 }
84}
85
86fn extract_enroll_state(args: &[String]) -> Option<PathBuf> {
87 args.windows(2)
88 .find(|w| w[0] == "--enroll-state")
89 .map(|w| PathBuf::from(&w[1]))
90}
91
92fn capture_env() -> Vec<(String, String)> {
93 let mut out = BTreeMap::new();
94 for (k, v) in std::env::vars() {
95 if ENV_PREFIXES.iter().any(|p| k.starts_with(p)) || k == "RUST_LOG" {
96 out.insert(k, v);
97 }
98 }
99 out.into_iter().collect()
100}
101
102fn extract_m4a_bind(args: &[String]) -> Option<String> {
103 args.windows(2)
104 .find(|w| w[0] == "--m4a-bind")
105 .map(|w| w[1].clone())
106 .or_else(|| std::env::var(crate::kit::COURIER_BIND_ENV).ok().filter(|v| !v.is_empty()))
107}
108
109fn write_sidecar_env(child_args: &[String], extra: &[(String, String)]) -> Result<(), String> {
112 let Some(path) = sidecar_env_path(child_args) else {
113 return Ok(());
114 };
115 if let Some(parent) = path.parent() {
116 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
117 }
118 let mut map = BTreeMap::new();
119 for (k, v) in load_sidecar_env(child_args) {
120 map.insert(k, v);
121 }
122 for (k, v) in capture_env() {
123 map.insert(k, v);
124 }
125 for (k, v) in extra {
126 map.insert(k.clone(), v.clone());
127 }
128 if let Some(bind) = extract_m4a_bind(child_args) {
129 map.insert(crate::kit::COURIER_BIND_ENV.to_owned(), bind);
130 }
131 let mut body = String::new();
132 for (k, v) in &map {
133 if k.is_empty() || k.starts_with('#') || k.contains('=') || k.contains('\n') {
134 continue;
135 }
136 body.push_str(k);
137 body.push('=');
138 body.push_str(v);
139 body.push('\n');
140 }
141 let tmp = path.with_extension("env.tmp");
142 fs::write(&tmp, body.as_bytes()).map_err(|e| format!("write {}: {e}", tmp.display()))?;
143 fs::rename(&tmp, &path).map_err(|e| format!("rename {}: {e}", path.display()))?;
144 Ok(())
145}
146
147fn windows_task_exe(exe: &Path) -> PathBuf {
148 #[cfg(windows)]
149 {
150 let w = exe.with_file_name("gate4agent-nodew.exe");
151 if w.is_file() {
152 return w;
153 }
154 }
155 let _ = exe;
156 exe.to_path_buf()
157}
158
159fn short_digest(s: &str) -> String {
160 use sha2::{Digest, Sha256};
161 let h = Sha256::digest(s.as_bytes());
162 hex::encode(&h[..6])
163}
164
165fn home_dir() -> Result<PathBuf, String> {
166 std::env::var_os("HOME")
167 .or_else(|| std::env::var_os("USERPROFILE"))
168 .map(PathBuf::from)
169 .ok_or_else(|| "HOME/USERPROFILE is unset".to_owned())
170}
171
172const NOT_SUPPORTED: &str = "service install/uninstall/status/restart: not supported yet on Windows (use gate4agent-nodew)";
174
175pub fn install(spec: &ServiceSpec) -> Result<String, String> {
176 require_admin_if_system(spec.system)?;
177 let _ = write_sidecar_env(&spec.args, &spec.env);
178 #[cfg(target_os = "linux")]
179 {
180 if systemd_available(spec.system) {
183 return install_linux(spec);
184 }
185 return install_xdg_autostart(spec);
186 }
187 #[cfg(target_os = "macos")]
188 {
189 return install_macos(spec);
190 }
191 #[cfg(windows)]
192 {
193 return Ok(format!(
195 "sidecar env written for MAIL4AGENT_BIND (WMI-safe); OS task install: use gate4agent-nodew / run-supervised"
196 ));
197 }
198 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
199 {
200 let _ = spec;
201 Err("service install is unsupported on this OS".into())
202 }
203}
204
205pub fn uninstall(system: bool) -> Result<String, String> {
206 require_admin_if_system(system)?;
207 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
209 #[cfg(target_os = "linux")]
210 {
211 return uninstall_linux(system, &name);
212 }
213 #[cfg(target_os = "macos")]
214 {
215 return uninstall_macos(system, &name);
216 }
217 #[cfg(windows)]
218 {
219 return Err(NOT_SUPPORTED.into());
220 }
221 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
222 {
223 let _ = (system, name);
224 Err("service uninstall is unsupported on this OS".into())
225 }
226}
227
228pub fn status(system: bool) -> Result<ServiceStatus, String> {
229 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
230 #[cfg(target_os = "linux")]
231 {
232 return status_linux(system, &name);
233 }
234 #[cfg(target_os = "macos")]
235 {
236 return status_macos(system, &name);
237 }
238 #[cfg(windows)]
239 {
240 return Err(NOT_SUPPORTED.into());
241 }
242 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
243 {
244 let _ = (system, name);
245 Err("service-status is unsupported on this OS".into())
246 }
247}
248
249pub fn restart(system: bool) -> Result<String, String> {
250 require_admin_if_system(system)?;
251 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
252 #[cfg(target_os = "linux")]
253 {
254 return restart_linux(system, &name);
255 }
256 #[cfg(target_os = "macos")]
257 {
258 return restart_macos(system, &name);
259 }
260 #[cfg(windows)]
261 {
262 return Err(NOT_SUPPORTED.into());
263 }
264 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
265 {
266 let _ = (system, name);
267 Err("service-restart is unsupported on this OS".into())
268 }
269}
270
271fn require_admin_if_system(system: bool) -> Result<(), String> {
272 if !system {
273 return Ok(());
274 }
275 if is_elevated() {
276 Ok(())
277 } else {
278 Err("--system requires administrator / root privileges".into())
279 }
280}
281
282#[cfg(windows)]
283fn is_elevated_windows() -> bool {
284 false
285}
286
287fn is_elevated() -> bool {
288 #[cfg(unix)]
289 {
290 unsafe { libc::geteuid() == 0 }
291 }
292 #[cfg(windows)]
293 {
294 is_elevated_windows()
295 }
296 #[cfg(all(not(unix), not(windows)))]
297 {
298 false
299 }
300}
301
302fn marker_path(system: bool) -> Result<PathBuf, String> {
303 let base = if system {
304 #[cfg(unix)]
305 {
306 PathBuf::from("/var/lib/gate4agent-node")
307 }
308 #[cfg(not(unix))]
309 {
310 std::env::var_os("ProgramData")
311 .map(PathBuf::from)
312 .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
313 .join("gate4agent-node")
314 }
315 } else {
316 home_dir()?.join(".local").join("state").join("gate4agent-node")
317 };
318 Ok(base.join("service-unit-name"))
319}
320
321fn save_unit_name(system: bool, name: &str) -> Result<(), String> {
322 let path = marker_path(system)?;
323 if let Some(parent) = path.parent() {
324 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
325 }
326 fs::write(&path, name).map_err(|e| format!("write {}: {e}", path.display()))
327}
328
329fn saved_unit_name(system: bool) -> Option<String> {
330 let path = marker_path(system).ok()?;
331 fs::read_to_string(path).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
332}
333
334fn clear_unit_name(system: bool) {
335 if let Ok(path) = marker_path(system) {
336 let _ = fs::remove_file(path);
337 }
338}
339
340
341fn systemd_available(system: bool) -> bool {
342 #[cfg(target_os = "linux")]
343 {
344 let _ = system;
345 Path::new("/run/systemd/system").is_dir()
346 || Path::new("/usr/lib/systemd/systemd").is_file()
347 || which_bin("systemctl").is_some()
348 }
349 #[cfg(not(target_os = "linux"))]
350 {
351 let _ = system;
352 false
353 }
354}
355
356fn which_bin(name: &str) -> Option<PathBuf> {
357 std::env::var_os("PATH").and_then(|paths| {
358 for dir in std::env::split_paths(&paths) {
359 let p = dir.join(name);
360 if p.is_file() {
361 return Some(p);
362 }
363 }
364 None
365 })
366}
367
368#[cfg(target_os = "linux")]
373fn install_xdg_autostart(spec: &ServiceSpec) -> Result<String, String> {
374 let dir = home_dir()?.join(".config").join("autostart");
375 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
376 let name = spec.unit_name();
377 let desktop = dir.join(format!("{name}.desktop"));
378 let exe = spec.exe.to_string_lossy();
379 let mut exec = format!("{exe} run-supervised --");
380 for a in &spec.args {
381 exec.push(' ');
382 if a.chars().any(|c| c.is_whitespace()) {
383 exec.push('"');
384 exec.push_str(&a.replace('"', "\\\""));
385 exec.push('"');
386 } else {
387 exec.push_str(a);
388 }
389 }
390 let body = format!(
391 "[Desktop Entry]\nType=Application\nName=gate4agent-node ({name})\nExec={exec}\nX-GNOME-Autostart-enabled=true\nNoDisplay=true\n"
392 );
393 fs::write(&desktop, body).map_err(|e| format!("write {}: {e}", desktop.display()))?;
394 save_unit_name(spec.system, &name)?;
395 Ok(format!(
396 "installed XDG autostart {} (no systemd; uses run-supervised)",
397 desktop.display()
398 ))
399}
400
401#[cfg(target_os = "linux")]
404fn unit_dir(system: bool) -> Result<PathBuf, String> {
405 if system {
406 Ok(PathBuf::from("/etc/systemd/system"))
407 } else {
408 Ok(home_dir()?.join(".config").join("systemd").join("user"))
409 }
410}
411
412#[cfg(target_os = "linux")]
413fn install_linux(spec: &ServiceSpec) -> Result<String, String> {
414 let name = spec.unit_name();
415 let dir = unit_dir(spec.system)?;
416 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
417 let path = dir.join(format!("{name}.service"));
418 let body = render_systemd_unit(&SystemdUnit {
420 description: "gate4agent-node",
421 exe: &spec.exe.to_string_lossy(),
422 args: &spec.args,
423 env: &spec.env,
424 supervised: false,
425 });
426 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
427 save_unit_name(spec.system, &name)?;
428
429 let mut notes = Vec::new();
430 if !spec.system {
431 match Command::new("loginctl")
432 .args(["enable-linger"])
433 .status()
434 {
435 Ok(s) if s.success() => notes.push("linger enabled".into()),
436 Ok(_) | Err(_) => notes.push(
437 "warning: loginctl enable-linger failed or unavailable; user units may stop at logout"
438 .into(),
439 ),
440 }
441 }
442 match run_ok(&mut systemctl_cmd(spec.system).args(["daemon-reload"]))
443 .and_then(|_| run_ok(&mut systemctl_cmd(spec.system).args(["enable", "--now", &name])))
444 {
445 Ok(()) => notes.push(format!("installed and started {name} ({})", path.display())),
446 Err(e) => notes.push(format!(
447 "wrote {} but systemctl failed ({e}); start later when the user bus is up",
448 path.display()
449 )),
450 }
451 Ok(notes.join("; "))
452}
453
454#[cfg(target_os = "linux")]
455fn uninstall_linux(system: bool, name: &str) -> Result<String, String> {
456 let _ = systemctl_cmd(system).args(["disable", "--now", name]).status();
457 let path = unit_dir(system)?.join(format!("{name}.service"));
458 let _ = fs::remove_file(&path);
459 let _ = systemctl_cmd(system).args(["daemon-reload"]).status();
460 clear_unit_name(system);
461 Ok(format!("removed {name}"))
462}
463
464#[cfg(target_os = "linux")]
465fn status_linux(system: bool, name: &str) -> Result<ServiceStatus, String> {
466 let path = unit_dir(system)?.join(format!("{name}.service"));
467 let installed = path.is_file();
468 let out = systemctl_cmd(system).args(["is-active", name]).output();
469 let (running, detail) = match out {
470 Ok(o) => {
471 let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
472 (s == "active", s)
473 }
474 Err(e) => (false, e.to_string()),
475 };
476 Ok(ServiceStatus {
477 installed,
478 running,
479 scope: if system { "system" } else { "user" },
480 unit_name: name.to_owned(),
481 detail,
482 node_health: probe_node_health(),
483 })
484}
485
486#[cfg(target_os = "linux")]
487fn restart_linux(system: bool, name: &str) -> Result<String, String> {
488 run_ok(&mut systemctl_cmd(system).args(["restart", name]))?;
489 Ok(format!("restarted {name}"))
490}
491
492#[cfg(target_os = "linux")]
493fn systemctl_cmd(system: bool) -> Command {
494 let mut c = Command::new("systemctl");
495 if !system {
496 c.arg("--user");
497 }
498 c
499}
500
501#[cfg(target_os = "macos")]
504fn plist_path(system: bool, label: &str) -> Result<PathBuf, String> {
505 if system {
506 Ok(PathBuf::from("/Library/LaunchDaemons").join(format!("{label}.plist")))
507 } else {
508 Ok(home_dir()?
509 .join("Library")
510 .join("LaunchAgents")
511 .join(format!("{label}.plist")))
512 }
513}
514
515#[cfg(target_os = "macos")]
516fn install_macos(spec: &ServiceSpec) -> Result<String, String> {
517 let label = spec.launchd_label();
518 let path = plist_path(spec.system, &label)?;
519 if let Some(parent) = path.parent() {
520 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
521 }
522 let body = render_launchd_plist(&LaunchPlist {
523 label: &label,
524 exe: &spec.exe.to_string_lossy(),
525 args: &spec.args,
526 env: &spec.env,
527 supervised: false, });
529 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
530 save_unit_name(spec.system, &spec.unit_name())?;
531 let domain = if spec.system {
532 "system".to_owned()
533 } else {
534 format!("gui/{}", unsafe { libc::getuid() })
535 };
536 let _ = Command::new("launchctl")
537 .args(["bootout", &domain, &path.to_string_lossy()])
538 .status();
539 run_ok(
540 &mut Command::new("launchctl").args(["bootstrap", &domain, &path.to_string_lossy()]),
541 )?;
542 let _ = Command::new("launchctl")
543 .args(["enable", &format!("{domain}/{label}")])
544 .status();
545 Ok(format!("installed {label} ({})", path.display()))
546}
547
548#[cfg(target_os = "macos")]
549fn uninstall_macos(system: bool, name: &str) -> Result<String, String> {
550 let label = format!("com.gate4agent.node.{name}");
551 let path = plist_path(system, &label)?;
552 let domain = if system {
553 "system".to_owned()
554 } else {
555 format!("gui/{}", unsafe { libc::getuid() })
556 };
557 let _ = Command::new("launchctl")
558 .args(["bootout", &domain, &path.to_string_lossy()])
559 .status();
560 let _ = fs::remove_file(&path);
561 clear_unit_name(system);
562 Ok(format!("removed {label}"))
563}
564
565#[cfg(target_os = "macos")]
566fn status_macos(system: bool, name: &str) -> Result<ServiceStatus, String> {
567 let label = format!("com.gate4agent.node.{name}");
568 let path = plist_path(system, &label)?;
569 let installed = path.is_file();
570 let out = Command::new("launchctl")
571 .args(["print", &format!("system/{label}")])
572 .output();
573 let running = match out {
575 Ok(o) if o.status.success() => true,
576 _ => Command::new("launchctl")
577 .args(["list", &label])
578 .status()
579 .map(|s| s.success())
580 .unwrap_or(false),
581 };
582 Ok(ServiceStatus {
583 installed,
584 running,
585 scope: if system { "system" } else { "user" },
586 unit_name: name.to_owned(),
587 detail: label,
588 node_health: probe_node_health(),
589 })
590}
591
592#[cfg(target_os = "macos")]
593fn restart_macos(system: bool, name: &str) -> Result<String, String> {
594 let label = format!("com.gate4agent.node.{name}");
595 let path = plist_path(system, &label)?;
596 let domain = if system {
597 "system".to_owned()
598 } else {
599 format!("gui/{}", unsafe { libc::getuid() })
600 };
601 let _ = Command::new("launchctl")
602 .args(["kickstart", "-k", &format!("{domain}/{label}")])
603 .status();
604 let _ = path;
605 Ok(format!("restarted {label}"))
606}
607
608fn probe_node_health() -> String {
612 let addr = std::env::var("GATE4AGENT_HEALTH_URL")
613 .or_else(|_| {
614 std::env::var("GATE4AGENT_API_LISTEN").map(|a| format!("http://{a}/health"))
615 })
616 .unwrap_or_else(|_| "http://127.0.0.1:18330/health".into());
617 let hostport = addr
618 .trim_start_matches("http://")
619 .trim_start_matches("https://")
620 .split('/')
621 .next()
622 .unwrap_or("127.0.0.1:18330");
623 use std::io::{Read, Write};
624 use std::net::ToSocketAddrs;
625 let sock = match hostport.to_socket_addrs() {
626 Ok(mut i) => match i.next() {
627 Some(a) => a,
628 None => return "bad_addr".into(),
629 },
630 Err(_) => return "bad_addr".into(),
631 };
632 let mut stream = match std::net::TcpStream::connect_timeout(&sock, Duration::from_secs(2)) {
633 Ok(s) => s,
634 Err(_) => return "down".into(),
635 };
636 let req = format!("GET /health HTTP/1.1
637Host: {hostport}
638Connection: close
639
640");
641 if stream.write_all(req.as_bytes()).is_err() {
642 return "unreachable".into();
643 }
644 let mut buf = vec![0u8; 4096];
645 let n = stream.read(&mut buf).unwrap_or(0);
646 let body = String::from_utf8_lossy(&buf[..n]);
647 if body.contains("\"ok\":true") || body.contains("\"ok\": true") {
648 "ok".into()
649 } else if n == 0 {
650 "no_response".into()
651 } else {
652 "responded_not_ok".into()
653 }
654}
655
656fn run_ok(cmd: &mut Command) -> Result<(), String> {
657 hide_console(cmd);
658 let out = cmd.output().map_err(|e| e.to_string())?;
659 if out.status.success() {
660 Ok(())
661 } else {
662 let stdout = String::from_utf8_lossy(&out.stdout);
663 let stderr = String::from_utf8_lossy(&out.stderr);
664 let combined = format!("{stdout} {stderr}").to_ascii_lowercase();
665 let hint = if combined.contains("access is denied") || combined.contains("access denied") {
666 " (Access denied: for a user-level task the XML must include your UserId/SID; try reinstalling this build, or run from an elevated prompt for --system)"
667 } else {
668 ""
669 };
670 Err(format!(
671 "command failed{hint}: stdout={stdout} stderr={stderr}"
672 ))
673 }
674}
675
676pub struct InstanceLock {
680 _file: File,
681 path: PathBuf,
682}
683
684pub fn acquire_instance_lock(enroll_state: &Path) -> Result<InstanceLock, String> {
685 fs::create_dir_all(enroll_state).map_err(|e| e.to_string())?;
686 let path = enroll_state.join("node.instance.lock");
687 #[cfg(windows)]
688 let file = {
689 use std::os::windows::fs::OpenOptionsExt;
690 OpenOptions::new()
691 .create(true)
692 .write(true)
693 .share_mode(0) .open(&path)
695 .map_err(|e| {
696 format!(
697 "another gate4agent-node already holds {} ({e})",
698 path.display()
699 )
700 })?
701 };
702 #[cfg(not(windows))]
703 let file = OpenOptions::new()
704 .create(true)
705 .write(true)
706 .open(&path)
707 .map_err(|e| format!("open {}: {e}", path.display()))?;
708 #[cfg(unix)]
709 {
710 use std::os::unix::io::AsRawFd;
711 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
712 if rc != 0 {
713 return Err(format!(
714 "another gate4agent-node already holds {}",
715 path.display()
716 ));
717 }
718 }
719 Ok(InstanceLock { _file: file, path })
720}
721
722pub fn install_panic_hook() {
723 let prev = std::panic::take_hook();
724 std::panic::set_hook(Box::new(move |info| {
725 let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
726 (*s).to_owned()
727 } else if let Some(s) = info.payload().downcast_ref::<String>() {
728 s.clone()
729 } else {
730 "panic".into()
731 };
732 let loc = info
733 .location()
734 .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
735 .unwrap_or_else(|| "unknown".into());
736 let _ = node_log::init_from_env();
737 node_log::error(
738 "process_panic",
739 &[("message", msg), ("location", loc)],
740 );
741 prev(info);
742 }));
743}
744
745pub fn run_supervised(child_args: &[String]) -> ! {
751 apply_sidecar_env(child_args);
752 ensure_node_log_from_args(child_args);
753 let _ = node_log::init_from_env();
754 install_panic_hook();
755 free_console();
756 ignore_console_close_events();
757 let exe = match std::env::current_exe() {
758 Ok(p) => p,
759 Err(e) => {
760 node_log::error("process_exit", &[("reason", format!("current_exe: {e}"))]);
761 std::process::exit(1);
762 }
763 };
764 let log_dir = child_log_dir(child_args);
765 let mut n: u64 = 0;
766 let mut backoff = Duration::from_secs(1);
767 loop {
768 n = n.saturating_add(1);
769 let stdout_path = log_dir.join("node.stdout.log");
770 let stderr_path = log_dir.join("node.stderr.log");
771 rotate_log_file(&stdout_path, 2_000_000);
772 rotate_log_file(&stderr_path, 2_000_000);
773 let mut cmd = Command::new(&exe);
774 cmd.args(child_args).stdin(Stdio::null());
775 match (OpenOptions::new().create(true).append(true).open(&stdout_path),
776 OpenOptions::new().create(true).append(true).open(&stderr_path))
777 {
778 (Ok(out), Ok(err)) => {
779 cmd.stdout(Stdio::from(out)).stderr(Stdio::from(err));
780 }
781 _ => {
782 #[cfg(windows)]
784 {
785 cmd.stdout(Stdio::null()).stderr(Stdio::null());
786 }
787 #[cfg(not(windows))]
788 {
789 cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
790 }
791 }
792 }
793 #[cfg(windows)]
794 {
795 hide_console_detached(&mut cmd);
796 }
797 cmd.env("GATE4AGENT_SUPERVISED_CHILD", "1");
798 let _ = write_sidecar_env(child_args, &[]);
800 for (k, v) in load_sidecar_env(child_args) {
801 cmd.env(&k, &v);
802 }
803 if let Some(bind) = extract_m4a_bind(child_args) {
804 cmd.env(crate::kit::COURIER_BIND_ENV, bind);
805 }
806 node_log::info(
807 "supervised_start",
808 &[
809 ("n", n.to_string()),
810 ("args_len", child_args.len().to_string()),
811 ("stderr_log", stderr_path.display().to_string()),
812 ],
813 );
814 let status = match cmd.status() {
815 Ok(s) => s,
816 Err(e) => {
817 node_log::error(
818 "process_exit",
819 &[
820 ("reason", format!("spawn: {e}")),
821 ("code", "spawn_error".into()),
822 ("supervised_n", n.to_string()),
823 ],
824 );
825 node_log::warn(
826 "supervised_restart",
827 &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
828 );
829 std::thread::sleep(backoff);
830 backoff = (backoff * 2).min(Duration::from_secs(30));
831 continue;
832 }
833 };
834 let code = status
835 .code()
836 .map(|c| c.to_string())
837 .unwrap_or_else(|| "signal".into());
838 let stderr_tail = read_tail(&stderr_path, 4_000);
839 node_log::warn(
840 "process_exit",
841 &[
842 ("reason", "child_exit".into()),
843 ("code", code.clone()),
844 ("supervised_n", n.to_string()),
845 ("stderr_tail", stderr_tail),
846 ],
847 );
848 node_log::warn(
849 "supervised_restart",
850 &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
851 );
852 std::thread::sleep(backoff);
853 backoff = (backoff * 2).min(Duration::from_secs(30));
854 if status.success() {
855 backoff = Duration::from_secs(1);
856 }
857 }
858}
859
860fn child_log_dir(child_args: &[String]) -> PathBuf {
861 if let Some(p) = extract_enroll_state(child_args) {
862 let _ = fs::create_dir_all(&p);
863 return p;
864 }
865 std::env::temp_dir().join("gate4agent-node-supervised")
866}
867
868fn ensure_node_log_from_args(child_args: &[String]) {
869 if std::env::var_os(crate::node_log::env_name()).is_some() {
870 return;
871 }
872 if let Some(dir) = extract_enroll_state(child_args) {
873 let path = crate::node_log::path_beside_state(&dir);
874 std::env::set_var(crate::node_log::env_name(), &path);
876 }
877}
878
879fn sidecar_env_path(child_args: &[String]) -> Option<PathBuf> {
880 let name = match extract_enroll_state(child_args) {
881 Some(p) => {
882 let key = p.to_string_lossy();
883 format!("gate4agent-node-{}", short_digest(&key))
884 }
885 None => "gate4agent-node".into(),
886 };
887 #[cfg(windows)]
888 {
889 let base = home_dir().ok()?.join("AppData").join("Local").join("gate4agent-node");
890 return Some(base.join(format!("{name}.env")));
891 }
892 #[cfg(not(windows))]
893 {
894 let base = home_dir().ok()?.join(".local").join("state").join("gate4agent-node");
895 Some(base.join(format!("{name}.env")))
896 }
897}
898
899fn load_sidecar_env(child_args: &[String]) -> Vec<(String, String)> {
900 let Some(path) = sidecar_env_path(child_args) else {
901 return Vec::new();
902 };
903 let Ok(text) = fs::read_to_string(&path) else {
904 return Vec::new();
905 };
906 let mut out = Vec::new();
907 for line in text.lines() {
908 let line = line.trim();
909 if line.is_empty() || line.starts_with('#') {
910 continue;
911 }
912 if let Some((k, v)) = line.split_once('=') {
913 out.push((k.to_owned(), v.to_owned()));
914 }
915 }
916 out
917}
918
919fn apply_sidecar_env(child_args: &[String]) {
920 for (k, v) in load_sidecar_env(child_args) {
921 if std::env::var_os(&k).is_none() {
922 std::env::set_var(&k, &v);
923 }
924 }
925}
926
927fn rotate_log_file(path: &Path, max_bytes: u64) {
928 let Ok(meta) = fs::metadata(path) else {
929 return;
930 };
931 if meta.len() < max_bytes {
932 return;
933 }
934 let bak = path.with_extension("log.1");
935 let _ = fs::remove_file(&bak);
936 let _ = fs::rename(path, bak);
937}
938
939fn read_tail(path: &Path, max_chars: usize) -> String {
940 let Ok(data) = fs::read(path) else {
941 return String::new();
942 };
943 let text = String::from_utf8_lossy(&data);
944 let t = text.trim();
945 if t.len() <= max_chars {
946 return t.to_owned();
947 }
948 t[t.len() - max_chars..].to_owned()
949}
950
951pub fn dispatch(raw_args: &[String]) -> Result<bool, String> {
954 let Some(cmd) = raw_args.first().map(String::as_str) else {
955 return Ok(false);
956 };
957 match cmd {
958 "install" => {
959 let (system, rest) = split_system_and_args(&raw_args[1..]);
960 let node_args = strip_leading_dd(&rest);
961 if node_args.is_empty() {
962 return Err("install requires node arguments after optional --system (and optional --)".into());
963 }
964 let spec = ServiceSpec::from_install_args(system, &node_args)?;
965 let msg = install(&spec)?;
966 println!("{msg}");
967 Ok(true)
968 }
969 "uninstall" => {
970 let (system, _) = split_system_and_args(&raw_args[1..]);
971 println!("{}", uninstall(system)?);
972 Ok(true)
973 }
974 "service-status" => {
975 let (system, _) = split_system_and_args(&raw_args[1..]);
976 let st = status(system)?;
977 println!(
978 "scope={} unit={} installed={} running={} node_health={} detail={}",
979 st.scope, st.unit_name, st.installed, st.running, st.node_health, st.detail
980 );
981 Ok(true)
982 }
983 "service-restart" => {
984 let (system, _) = split_system_and_args(&raw_args[1..]);
985 println!("{}", restart(system)?);
986 Ok(true)
987 }
988 "run-supervised" => {
989 let rest = strip_leading_dd(&raw_args[1..]);
990 if rest.is_empty() {
991 return Err("run-supervised requires `--` and node arguments".into());
992 }
993 run_supervised(&rest);
994 }
995 _ => Ok(false),
996 }
997}
998
999fn split_system_and_args(args: &[String]) -> (bool, Vec<String>) {
1000 let mut system = false;
1001 let mut rest = Vec::new();
1002 for a in args {
1003 if a == "--system" {
1004 system = true;
1005 } else {
1006 rest.push(a.clone());
1007 }
1008 }
1009 (system, rest)
1010}
1011
1012fn strip_leading_dd(args: &[String]) -> Vec<String> {
1013 match args.first().map(String::as_str) {
1014 Some("--") => args[1..].to_vec(),
1015 _ => args.to_vec(),
1016 }
1017}
1018
1019#[cfg(test)]
1020mod spec_tests {
1021 use super::*;
1022
1023 #[test]
1024 fn unit_name_stable_for_enroll_path() {
1025 let spec = ServiceSpec {
1026 system: false,
1027 exe: PathBuf::from("/bin/gate4agent-node"),
1028 args: vec![
1029 "--enrolled".into(),
1030 "--enroll-state".into(),
1031 "/tmp/enroll-a".into(),
1032 ],
1033 env: vec![],
1034 enroll_state: Some(PathBuf::from("/tmp/enroll-a")),
1035 };
1036 let n = spec.unit_name();
1037 assert!(n.starts_with("gate4agent-node-"));
1038 assert_eq!(n, spec.unit_name());
1039 assert!(!n.contains('/'));
1040 }
1041
1042 #[test]
1043 fn extract_enroll_state_from_args() {
1044 let args = vec![
1045 "--enrolled".into(),
1046 "--enroll-state".into(),
1047 "/var/lib/x".into(),
1048 ];
1049 assert_eq!(
1050 extract_enroll_state(&args),
1051 Some(PathBuf::from("/var/lib/x"))
1052 );
1053 }
1054
1055 #[test]
1056 fn extract_m4a_bind_from_args() {
1057 let args = vec![
1058 "--enrolled".into(),
1059 "--m4a-bind".into(),
1060 "127.0.0.1:18401".into(),
1061 ];
1062 assert_eq!(extract_m4a_bind(&args).as_deref(), Some("127.0.0.1:18401"));
1063 }
1064}