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