Skip to main content

hatchery_node/service/
mod.rs

1//! OS-native supervision for the node binary: install / uninstall / status /
2//! restart, plus an in-process `run-supervised` parent for platforms where the
3//! OS restart policy is incomplete (Windows Task Scheduler).
4
5mod 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, ignore_console_close_events,
17    set_creation_flags, CREATE_NO_WINDOW,
18};
19
20pub use render::{
21    render_launchd_plist, render_schtasks_xml, render_systemd_unit, LaunchPlist, SchtasksXml,
22    SystemdUnit,
23};
24
25/// Prefixes of environment variables captured at install time into the unit.
26const ENV_PREFIXES: &[&str] = &["GATE4AGENT_", "HATCHERY_", "RUST_LOG"];
27
28#[derive(Clone, Debug)]
29pub struct ServiceSpec {
30    pub system: bool,
31    pub exe: PathBuf,
32    pub args: Vec<String>,
33    pub env: Vec<(String, String)>,
34    pub enroll_state: Option<PathBuf>,
35}
36
37#[derive(Clone, Debug)]
38pub struct ServiceStatus {
39    pub installed: bool,
40    pub running: bool,
41    pub scope: &'static str,
42    pub unit_name: String,
43    pub detail: String,
44    /// Best-effort probe of the node HTTP health endpoint from saved args / env.
45    pub node_health: String,
46}
47
48impl ServiceSpec {
49    pub fn from_install_args(system: bool, node_args: &[String]) -> Result<Self, String> {
50        let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
51        let enroll_state = extract_enroll_state(node_args);
52        let env = capture_env();
53        Ok(Self {
54            system,
55            exe,
56            args: node_args.to_vec(),
57            env,
58            enroll_state,
59        })
60    }
61
62    pub fn unit_name(&self) -> String {
63        match &self.enroll_state {
64            Some(p) => {
65                let key = p.to_string_lossy();
66                let digest = short_digest(&key);
67                format!("gate4agent-node-{digest}")
68            }
69            None => "gate4agent-node".to_owned(),
70        }
71    }
72
73    pub fn launchd_label(&self) -> String {
74        format!("com.gate4agent.node.{}", self.unit_name())
75    }
76}
77
78fn extract_enroll_state(args: &[String]) -> Option<PathBuf> {
79    args.windows(2)
80        .find(|w| w[0] == "--enroll-state")
81        .map(|w| PathBuf::from(&w[1]))
82}
83
84fn capture_env() -> Vec<(String, String)> {
85    let mut out = BTreeMap::new();
86    for (k, v) in std::env::vars() {
87        if ENV_PREFIXES.iter().any(|p| k.starts_with(p)) || k == "RUST_LOG" {
88            out.insert(k, v);
89        }
90    }
91    out.into_iter().collect()
92}
93
94fn windows_task_exe(exe: &Path) -> PathBuf {
95    #[cfg(windows)]
96    {
97        let w = exe.with_file_name("gate4agent-nodew.exe");
98        if w.is_file() {
99            return w;
100        }
101    }
102    let _ = exe;
103    exe.to_path_buf()
104}
105
106fn short_digest(s: &str) -> String {
107    use sha2::{Digest, Sha256};
108    let h = Sha256::digest(s.as_bytes());
109    hex::encode(&h[..6])
110}
111
112fn home_dir() -> Result<PathBuf, String> {
113    std::env::var_os("HOME")
114        .or_else(|| std::env::var_os("USERPROFILE"))
115        .map(PathBuf::from)
116        .ok_or_else(|| "HOME/USERPROFILE is unset".to_owned())
117}
118
119/// Install the node under the OS supervisor for this platform.
120const NOT_SUPPORTED: &str = "service install/uninstall/status/restart: not supported yet on Windows (use gate4agent-nodew)";
121
122pub fn install(spec: &ServiceSpec) -> Result<String, String> {
123    require_admin_if_system(spec.system)?;
124    #[cfg(target_os = "linux")]
125    {
126        return install_linux(spec);
127    }
128    #[cfg(target_os = "macos")]
129    {
130        return install_macos(spec);
131    }
132    #[cfg(windows)]
133    {
134        return Err(NOT_SUPPORTED.into());
135    }
136    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
137    {
138        let _ = spec;
139        Err("service install is unsupported on this OS".into())
140    }
141}
142
143pub fn uninstall(system: bool) -> Result<String, String> {
144    require_admin_if_system(system)?;
145    // Reconstruct name from saved marker if present; else default.
146    let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
147    #[cfg(target_os = "linux")]
148    {
149        return uninstall_linux(system, &name);
150    }
151    #[cfg(target_os = "macos")]
152    {
153        return uninstall_macos(system, &name);
154    }
155    #[cfg(windows)]
156    {
157        return Err(NOT_SUPPORTED.into());
158    }
159    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
160    {
161        let _ = (system, name);
162        Err("service uninstall is unsupported on this OS".into())
163    }
164}
165
166pub fn status(system: bool) -> Result<ServiceStatus, String> {
167    let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
168    #[cfg(target_os = "linux")]
169    {
170        return status_linux(system, &name);
171    }
172    #[cfg(target_os = "macos")]
173    {
174        return status_macos(system, &name);
175    }
176    #[cfg(windows)]
177    {
178        return Err(NOT_SUPPORTED.into());
179    }
180    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
181    {
182        let _ = (system, name);
183        Err("service-status is unsupported on this OS".into())
184    }
185}
186
187pub fn restart(system: bool) -> Result<String, String> {
188    require_admin_if_system(system)?;
189    let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
190    #[cfg(target_os = "linux")]
191    {
192        return restart_linux(system, &name);
193    }
194    #[cfg(target_os = "macos")]
195    {
196        return restart_macos(system, &name);
197    }
198    #[cfg(windows)]
199    {
200        return Err(NOT_SUPPORTED.into());
201    }
202    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
203    {
204        let _ = (system, name);
205        Err("service-restart is unsupported on this OS".into())
206    }
207}
208
209fn require_admin_if_system(system: bool) -> Result<(), String> {
210    if !system {
211        return Ok(());
212    }
213    if is_elevated() {
214        Ok(())
215    } else {
216        Err("--system requires administrator / root privileges".into())
217    }
218}
219
220#[cfg(windows)]
221fn is_elevated_windows() -> bool {
222    false
223}
224
225fn is_elevated() -> bool {
226    #[cfg(unix)]
227    {
228        unsafe { libc::geteuid() == 0 }
229    }
230    #[cfg(windows)]
231    {
232        is_elevated_windows()
233    }
234    #[cfg(all(not(unix), not(windows)))]
235    {
236        false
237    }
238}
239
240fn marker_path(system: bool) -> Result<PathBuf, String> {
241    let base = if system {
242        #[cfg(unix)]
243        {
244            PathBuf::from("/var/lib/gate4agent-node")
245        }
246        #[cfg(not(unix))]
247        {
248            std::env::var_os("ProgramData")
249                .map(PathBuf::from)
250                .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
251                .join("gate4agent-node")
252        }
253    } else {
254        home_dir()?.join(".local").join("state").join("gate4agent-node")
255    };
256    Ok(base.join("service-unit-name"))
257}
258
259fn save_unit_name(system: bool, name: &str) -> Result<(), String> {
260    let path = marker_path(system)?;
261    if let Some(parent) = path.parent() {
262        fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
263    }
264    fs::write(&path, name).map_err(|e| format!("write {}: {e}", path.display()))
265}
266
267fn saved_unit_name(system: bool) -> Option<String> {
268    let path = marker_path(system).ok()?;
269    fs::read_to_string(path).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
270}
271
272fn clear_unit_name(system: bool) {
273    if let Ok(path) = marker_path(system) {
274        let _ = fs::remove_file(path);
275    }
276}
277
278// ----- Linux -----
279
280#[cfg(target_os = "linux")]
281fn unit_dir(system: bool) -> Result<PathBuf, String> {
282    if system {
283        Ok(PathBuf::from("/etc/systemd/system"))
284    } else {
285        Ok(home_dir()?.join(".config").join("systemd").join("user"))
286    }
287}
288
289#[cfg(target_os = "linux")]
290fn install_linux(spec: &ServiceSpec) -> Result<String, String> {
291    let name = spec.unit_name();
292    let dir = unit_dir(spec.system)?;
293    fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
294    let path = dir.join(format!("{name}.service"));
295    // systemd itself restarts; supervised wrapper not required on Linux.
296    let body = render_systemd_unit(&SystemdUnit {
297        description: "gate4agent-node",
298        exe: &spec.exe.to_string_lossy(),
299        args: &spec.args,
300        env: &spec.env,
301        supervised: false,
302    });
303    fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
304    save_unit_name(spec.system, &name)?;
305
306    let mut notes = Vec::new();
307    if !spec.system {
308        match Command::new("loginctl")
309            .args(["enable-linger"])
310            .status()
311        {
312            Ok(s) if s.success() => notes.push("linger enabled".into()),
313            Ok(_) | Err(_) => notes.push(
314                "warning: loginctl enable-linger failed or unavailable; user units may stop at logout"
315                    .into(),
316            ),
317        }
318    }
319    match run_ok(&mut systemctl_cmd(spec.system).args(["daemon-reload"]))
320        .and_then(|_| run_ok(&mut systemctl_cmd(spec.system).args(["enable", "--now", &name])))
321    {
322        Ok(()) => notes.push(format!("installed and started {name} ({})", path.display())),
323        Err(e) => notes.push(format!(
324            "wrote {} but systemctl failed ({e}); start later when the user bus is up",
325            path.display()
326        )),
327    }
328    Ok(notes.join("; "))
329}
330
331#[cfg(target_os = "linux")]
332fn uninstall_linux(system: bool, name: &str) -> Result<String, String> {
333    let _ = systemctl_cmd(system).args(["disable", "--now", name]).status();
334    let path = unit_dir(system)?.join(format!("{name}.service"));
335    let _ = fs::remove_file(&path);
336    let _ = systemctl_cmd(system).args(["daemon-reload"]).status();
337    clear_unit_name(system);
338    Ok(format!("removed {name}"))
339}
340
341#[cfg(target_os = "linux")]
342fn status_linux(system: bool, name: &str) -> Result<ServiceStatus, String> {
343    let path = unit_dir(system)?.join(format!("{name}.service"));
344    let installed = path.is_file();
345    let out = systemctl_cmd(system).args(["is-active", name]).output();
346    let (running, detail) = match out {
347        Ok(o) => {
348            let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
349            (s == "active", s)
350        }
351        Err(e) => (false, e.to_string()),
352    };
353    Ok(ServiceStatus {
354        installed,
355        running,
356        scope: if system { "system" } else { "user" },
357        unit_name: name.to_owned(),
358        detail,
359        node_health: probe_node_health(),
360    })
361}
362
363#[cfg(target_os = "linux")]
364fn restart_linux(system: bool, name: &str) -> Result<String, String> {
365    run_ok(&mut systemctl_cmd(system).args(["restart", name]))?;
366    Ok(format!("restarted {name}"))
367}
368
369#[cfg(target_os = "linux")]
370fn systemctl_cmd(system: bool) -> Command {
371    let mut c = Command::new("systemctl");
372    if !system {
373        c.arg("--user");
374    }
375    c
376}
377
378// ----- macOS -----
379
380#[cfg(target_os = "macos")]
381fn plist_path(system: bool, label: &str) -> Result<PathBuf, String> {
382    if system {
383        Ok(PathBuf::from("/Library/LaunchDaemons").join(format!("{label}.plist")))
384    } else {
385        Ok(home_dir()?
386            .join("Library")
387            .join("LaunchAgents")
388            .join(format!("{label}.plist")))
389    }
390}
391
392#[cfg(target_os = "macos")]
393fn install_macos(spec: &ServiceSpec) -> Result<String, String> {
394    let label = spec.launchd_label();
395    let path = plist_path(spec.system, &label)?;
396    if let Some(parent) = path.parent() {
397        fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
398    }
399    let body = render_launchd_plist(&LaunchPlist {
400        label: &label,
401        exe: &spec.exe.to_string_lossy(),
402        args: &spec.args,
403        env: &spec.env,
404        supervised: false, // KeepAlive is enough on launchd
405    });
406    fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
407    save_unit_name(spec.system, &spec.unit_name())?;
408    let domain = if spec.system {
409        "system".to_owned()
410    } else {
411        format!("gui/{}", unsafe { libc::getuid() })
412    };
413    let _ = Command::new("launchctl")
414        .args(["bootout", &domain, &path.to_string_lossy()])
415        .status();
416    run_ok(
417        &mut Command::new("launchctl").args(["bootstrap", &domain, &path.to_string_lossy()]),
418    )?;
419    let _ = Command::new("launchctl")
420        .args(["enable", &format!("{domain}/{label}")])
421        .status();
422    Ok(format!("installed {label} ({})", path.display()))
423}
424
425#[cfg(target_os = "macos")]
426fn uninstall_macos(system: bool, name: &str) -> Result<String, String> {
427    let label = format!("com.gate4agent.node.{name}");
428    let path = plist_path(system, &label)?;
429    let domain = if system {
430        "system".to_owned()
431    } else {
432        format!("gui/{}", unsafe { libc::getuid() })
433    };
434    let _ = Command::new("launchctl")
435        .args(["bootout", &domain, &path.to_string_lossy()])
436        .status();
437    let _ = fs::remove_file(&path);
438    clear_unit_name(system);
439    Ok(format!("removed {label}"))
440}
441
442#[cfg(target_os = "macos")]
443fn status_macos(system: bool, name: &str) -> Result<ServiceStatus, String> {
444    let label = format!("com.gate4agent.node.{name}");
445    let path = plist_path(system, &label)?;
446    let installed = path.is_file();
447    let out = Command::new("launchctl")
448        .args(["print", &format!("system/{label}")])
449        .output();
450    // try user domain too
451    let running = match out {
452        Ok(o) if o.status.success() => true,
453        _ => Command::new("launchctl")
454            .args(["list", &label])
455            .status()
456            .map(|s| s.success())
457            .unwrap_or(false),
458    };
459    Ok(ServiceStatus {
460        installed,
461        running,
462        scope: if system { "system" } else { "user" },
463        unit_name: name.to_owned(),
464        detail: label,
465        node_health: probe_node_health(),
466    })
467}
468
469#[cfg(target_os = "macos")]
470fn restart_macos(system: bool, name: &str) -> Result<String, String> {
471    let label = format!("com.gate4agent.node.{name}");
472    let path = plist_path(system, &label)?;
473    let domain = if system {
474        "system".to_owned()
475    } else {
476        format!("gui/{}", unsafe { libc::getuid() })
477    };
478    let _ = Command::new("launchctl")
479        .args(["kickstart", "-k", &format!("{domain}/{label}")])
480        .status();
481    let _ = path;
482    Ok(format!("restarted {label}"))
483}
484
485// ----- Windows -----
486
487
488fn probe_node_health() -> String {
489    let addr = std::env::var("GATE4AGENT_HEALTH_URL")
490        .or_else(|_| {
491            std::env::var("GATE4AGENT_API_LISTEN").map(|a| format!("http://{a}/health"))
492        })
493        .unwrap_or_else(|_| "http://127.0.0.1:18330/health".into());
494    let hostport = addr
495        .trim_start_matches("http://")
496        .trim_start_matches("https://")
497        .split('/')
498        .next()
499        .unwrap_or("127.0.0.1:18330");
500    use std::io::{Read, Write};
501    use std::net::ToSocketAddrs;
502    let sock = match hostport.to_socket_addrs() {
503        Ok(mut i) => match i.next() {
504            Some(a) => a,
505            None => return "bad_addr".into(),
506        },
507        Err(_) => return "bad_addr".into(),
508    };
509    let mut stream = match std::net::TcpStream::connect_timeout(&sock, Duration::from_secs(2)) {
510        Ok(s) => s,
511        Err(_) => return "down".into(),
512    };
513    let req = format!("GET /health HTTP/1.1
514Host: {hostport}
515Connection: close
516
517");
518    if stream.write_all(req.as_bytes()).is_err() {
519        return "unreachable".into();
520    }
521    let mut buf = vec![0u8; 4096];
522    let n = stream.read(&mut buf).unwrap_or(0);
523    let body = String::from_utf8_lossy(&buf[..n]);
524    if body.contains("\"ok\":true") || body.contains("\"ok\": true") {
525        "ok".into()
526    } else if n == 0 {
527        "no_response".into()
528    } else {
529        "responded_not_ok".into()
530    }
531}
532
533fn run_ok(cmd: &mut Command) -> Result<(), String> {
534    hide_console(cmd);
535    let out = cmd.output().map_err(|e| e.to_string())?;
536    if out.status.success() {
537        Ok(())
538    } else {
539        let stdout = String::from_utf8_lossy(&out.stdout);
540        let stderr = String::from_utf8_lossy(&out.stderr);
541        let combined = format!("{stdout} {stderr}").to_ascii_lowercase();
542        let hint = if combined.contains("access is denied") || combined.contains("access denied") {
543            " (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)"
544        } else {
545            ""
546        };
547        Err(format!(
548            "command failed{hint}: stdout={stdout} stderr={stderr}"
549        ))
550    }
551}
552
553// ----- run-supervised + instance lock + panic hook -----
554
555/// Hold an exclusive lock on `{enroll-state}/node.instance.lock`.
556pub struct InstanceLock {
557    _file: File,
558    path: PathBuf,
559}
560
561pub fn acquire_instance_lock(enroll_state: &Path) -> Result<InstanceLock, String> {
562    fs::create_dir_all(enroll_state).map_err(|e| e.to_string())?;
563    let path = enroll_state.join("node.instance.lock");
564    #[cfg(windows)]
565    let file = {
566        use std::os::windows::fs::OpenOptionsExt;
567        OpenOptions::new()
568            .create(true)
569            .write(true)
570            .share_mode(0) // deny share: second open fails while held
571            .open(&path)
572            .map_err(|e| {
573                format!(
574                    "another gate4agent-node already holds {} ({e})",
575                    path.display()
576                )
577            })?
578    };
579    #[cfg(not(windows))]
580    let file = OpenOptions::new()
581        .create(true)
582        .write(true)
583        .open(&path)
584        .map_err(|e| format!("open {}: {e}", path.display()))?;
585    #[cfg(unix)]
586    {
587        use std::os::unix::io::AsRawFd;
588        let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
589        if rc != 0 {
590            return Err(format!(
591                "another gate4agent-node already holds {}",
592                path.display()
593            ));
594        }
595    }
596    Ok(InstanceLock { _file: file, path })
597}
598
599pub fn install_panic_hook() {
600    let prev = std::panic::take_hook();
601    std::panic::set_hook(Box::new(move |info| {
602        let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
603            (*s).to_owned()
604        } else if let Some(s) = info.payload().downcast_ref::<String>() {
605            s.clone()
606        } else {
607            "panic".into()
608        };
609        let loc = info
610            .location()
611            .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
612            .unwrap_or_else(|| "unknown".into());
613        let _ = node_log::init_from_env();
614        node_log::error(
615            "process_panic",
616            &[("message", msg), ("location", loc)],
617        );
618        prev(info);
619    }));
620}
621
622/// Parent process: respawn `current_exe` with `child_args` until forever.
623///
624/// Child stdout/stderr go to files under the enroll-state (or a temp dir) so a
625/// headless Windows spawn never inherits invalid console handles (which made
626/// `println!`/`eprintln!` panic). Interactive CLI never enters this path.
627pub fn run_supervised(child_args: &[String]) -> ! {
628    apply_sidecar_env(child_args);
629    ensure_node_log_from_args(child_args);
630    let _ = node_log::init_from_env();
631    install_panic_hook();
632    free_console();
633    ignore_console_close_events();
634    let exe = match std::env::current_exe() {
635        Ok(p) => p,
636        Err(e) => {
637            node_log::error("process_exit", &[("reason", format!("current_exe: {e}"))]);
638            std::process::exit(1);
639        }
640    };
641    let log_dir = child_log_dir(child_args);
642    let mut n: u64 = 0;
643    let mut backoff = Duration::from_secs(1);
644    loop {
645        n = n.saturating_add(1);
646        let stdout_path = log_dir.join("node.stdout.log");
647        let stderr_path = log_dir.join("node.stderr.log");
648        rotate_log_file(&stdout_path, 2_000_000);
649        rotate_log_file(&stderr_path, 2_000_000);
650        let mut cmd = Command::new(&exe);
651        cmd.args(child_args).stdin(Stdio::null());
652        match (OpenOptions::new().create(true).append(true).open(&stdout_path),
653               OpenOptions::new().create(true).append(true).open(&stderr_path))
654        {
655            (Ok(out), Ok(err)) => {
656                cmd.stdout(Stdio::from(out)).stderr(Stdio::from(err));
657            }
658            _ => {
659                // Last resort: inherit (Unix) or null (Windows) — never panic.
660                #[cfg(windows)]
661                {
662                    cmd.stdout(Stdio::null()).stderr(Stdio::null());
663                }
664                #[cfg(not(windows))]
665                {
666                    cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
667                }
668            }
669        }
670        #[cfg(windows)]
671        {
672            // CREATE_NO_WINDOW only — not DETACHED_PROCESS: with redirected
673            // stdio, DETACHED was redundant and historically hid failures.
674            set_creation_flags(&mut cmd, CREATE_NO_WINDOW);
675        }
676        cmd.env("GATE4AGENT_SUPERVISED_CHILD", "1");
677        // Re-apply sidecar env each spawn (install may have refreshed it).
678        for (k, v) in load_sidecar_env(child_args) {
679            cmd.env(k, v);
680        }
681        node_log::info(
682            "supervised_start",
683            &[
684                ("n", n.to_string()),
685                ("args_len", child_args.len().to_string()),
686                ("stderr_log", stderr_path.display().to_string()),
687            ],
688        );
689        let status = match cmd.status() {
690            Ok(s) => s,
691            Err(e) => {
692                node_log::error(
693                    "process_exit",
694                    &[
695                        ("reason", format!("spawn: {e}")),
696                        ("code", "spawn_error".into()),
697                        ("supervised_n", n.to_string()),
698                    ],
699                );
700                node_log::warn(
701                    "supervised_restart",
702                    &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
703                );
704                std::thread::sleep(backoff);
705                backoff = (backoff * 2).min(Duration::from_secs(30));
706                continue;
707            }
708        };
709        let code = status
710            .code()
711            .map(|c| c.to_string())
712            .unwrap_or_else(|| "signal".into());
713        let stderr_tail = read_tail(&stderr_path, 4_000);
714        node_log::warn(
715            "process_exit",
716            &[
717                ("reason", "child_exit".into()),
718                ("code", code.clone()),
719                ("supervised_n", n.to_string()),
720                ("stderr_tail", stderr_tail),
721            ],
722        );
723        node_log::warn(
724            "supervised_restart",
725            &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
726        );
727        std::thread::sleep(backoff);
728        backoff = (backoff * 2).min(Duration::from_secs(30));
729        if status.success() {
730            backoff = Duration::from_secs(1);
731        }
732    }
733}
734
735fn child_log_dir(child_args: &[String]) -> PathBuf {
736    if let Some(p) = extract_enroll_state(child_args) {
737        let _ = fs::create_dir_all(&p);
738        return p;
739    }
740    std::env::temp_dir().join("gate4agent-node-supervised")
741}
742
743fn ensure_node_log_from_args(child_args: &[String]) {
744    if std::env::var_os(crate::node_log::env_name()).is_some() {
745        return;
746    }
747    if let Some(dir) = extract_enroll_state(child_args) {
748        let path = crate::node_log::path_beside_state(&dir);
749        // SAFETY: single-threaded before spawn loop; sets log path for parent.
750        std::env::set_var(crate::node_log::env_name(), &path);
751    }
752}
753
754fn sidecar_env_path(child_args: &[String]) -> Option<PathBuf> {
755    let name = match extract_enroll_state(child_args) {
756        Some(p) => {
757            let key = p.to_string_lossy();
758            format!("gate4agent-node-{}", short_digest(&key))
759        }
760        None => "gate4agent-node".into(),
761    };
762    #[cfg(windows)]
763    {
764        let base = home_dir().ok()?.join("AppData").join("Local").join("gate4agent-node");
765        return Some(base.join(format!("{name}.env")));
766    }
767    #[cfg(not(windows))]
768    {
769        let base = home_dir().ok()?.join(".local").join("state").join("gate4agent-node");
770        Some(base.join(format!("{name}.env")))
771    }
772}
773
774fn load_sidecar_env(child_args: &[String]) -> Vec<(String, String)> {
775    let Some(path) = sidecar_env_path(child_args) else {
776        return Vec::new();
777    };
778    let Ok(text) = fs::read_to_string(&path) else {
779        return Vec::new();
780    };
781    let mut out = Vec::new();
782    for line in text.lines() {
783        let line = line.trim();
784        if line.is_empty() || line.starts_with('#') {
785            continue;
786        }
787        if let Some((k, v)) = line.split_once('=') {
788            out.push((k.to_owned(), v.to_owned()));
789        }
790    }
791    out
792}
793
794fn apply_sidecar_env(child_args: &[String]) {
795    for (k, v) in load_sidecar_env(child_args) {
796        if std::env::var_os(&k).is_none() {
797            std::env::set_var(&k, &v);
798        }
799    }
800}
801
802fn rotate_log_file(path: &Path, max_bytes: u64) {
803    let Ok(meta) = fs::metadata(path) else {
804        return;
805    };
806    if meta.len() < max_bytes {
807        return;
808    }
809    let bak = path.with_extension("log.1");
810    let _ = fs::remove_file(&bak);
811    let _ = fs::rename(path, bak);
812}
813
814fn read_tail(path: &Path, max_chars: usize) -> String {
815    let Ok(data) = fs::read(path) else {
816        return String::new();
817    };
818    let text = String::from_utf8_lossy(&data);
819    let t = text.trim();
820    if t.len() <= max_chars {
821        return t.to_owned();
822    }
823    t[t.len() - max_chars..].to_owned()
824}
825
826/// Parse `install|uninstall|service-status|service-restart|run-supervised`.
827/// Returns `true` if handled (caller should return).
828pub fn dispatch(raw_args: &[String]) -> Result<bool, String> {
829    let Some(cmd) = raw_args.first().map(String::as_str) else {
830        return Ok(false);
831    };
832    match cmd {
833        "install" => {
834            let (system, rest) = split_system_and_args(&raw_args[1..]);
835            let node_args = strip_leading_dd(&rest);
836            if node_args.is_empty() {
837                return Err("install requires node arguments after optional --system (and optional --)".into());
838            }
839            let spec = ServiceSpec::from_install_args(system, &node_args)?;
840            let msg = install(&spec)?;
841            println!("{msg}");
842            Ok(true)
843        }
844        "uninstall" => {
845            let (system, _) = split_system_and_args(&raw_args[1..]);
846            println!("{}", uninstall(system)?);
847            Ok(true)
848        }
849        "service-status" => {
850            let (system, _) = split_system_and_args(&raw_args[1..]);
851            let st = status(system)?;
852            println!(
853                "scope={} unit={} installed={} running={} node_health={} detail={}",
854                st.scope, st.unit_name, st.installed, st.running, st.node_health, st.detail
855            );
856            Ok(true)
857        }
858        "service-restart" => {
859            let (system, _) = split_system_and_args(&raw_args[1..]);
860            println!("{}", restart(system)?);
861            Ok(true)
862        }
863        "run-supervised" => {
864            let rest = strip_leading_dd(&raw_args[1..]);
865            if rest.is_empty() {
866                return Err("run-supervised requires `--` and node arguments".into());
867            }
868            run_supervised(&rest);
869        }
870        _ => Ok(false),
871    }
872}
873
874fn split_system_and_args(args: &[String]) -> (bool, Vec<String>) {
875    let mut system = false;
876    let mut rest = Vec::new();
877    for a in args {
878        if a == "--system" {
879            system = true;
880        } else {
881            rest.push(a.clone());
882        }
883    }
884    (system, rest)
885}
886
887fn strip_leading_dd(args: &[String]) -> Vec<String> {
888    match args.first().map(String::as_str) {
889        Some("--") => args[1..].to_vec(),
890        _ => args.to_vec(),
891    }
892}
893
894#[cfg(test)]
895mod spec_tests {
896    use super::*;
897
898    #[test]
899    fn unit_name_stable_for_enroll_path() {
900        let spec = ServiceSpec {
901            system: false,
902            exe: PathBuf::from("/bin/gate4agent-node"),
903            args: vec![
904                "--enrolled".into(),
905                "--enroll-state".into(),
906                "/tmp/enroll-a".into(),
907            ],
908            env: vec![],
909            enroll_state: Some(PathBuf::from("/tmp/enroll-a")),
910        };
911        let n = spec.unit_name();
912        assert!(n.starts_with("gate4agent-node-"));
913        assert_eq!(n, spec.unit_name());
914        assert!(!n.contains('/'));
915    }
916
917    #[test]
918    fn extract_enroll_state_from_args() {
919        let args = vec![
920            "--enrolled".into(),
921            "--enroll-state".into(),
922            "/var/lib/x".into(),
923        ];
924        assert_eq!(
925            extract_enroll_state(&args),
926            Some(PathBuf::from("/var/lib/x"))
927        );
928    }
929}