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.
25/// `MAIL4AGENT_BIND` must be included: WMI/schtasks often spawn without env, and the
26/// supervised child reads bind from the sidecar `.env` written at install/start.
27const 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    /// Best-effort probe of the node HTTP health endpoint from saved args / env.
46    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
109/// Persist courier bind (and other sidecar keys) so WMI/schtasks children that get no env
110/// still see `MAIL4AGENT_BIND` via [`load_sidecar_env`].
111fn 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
172/// Install the node under the OS supervisor for this platform.
173const 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        // Prefer systemd when available; otherwise XDG Autostart + run-supervised
181        // (no cron). Hatchery-box uses an equivalent shell loop (`run.sh`).
182        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        // Persist sidecar env for WMI/schtasks; full schtasks install still via nodew path.
194        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    // Reconstruct name from saved marker if present; else default.
208    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/// Login-session autostart without systemd/cron: `~/.config/autostart/*.desktop`
369/// launching `run-supervised -- <node args>`. Survives reboot when the user
370/// (or display manager) starts a graphical/session login; for headless boxes
371/// prefer an external supervisor equivalent to hatchery-box `run.sh`.
372#[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// ----- Linux -----
402
403#[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    // systemd itself restarts; supervised wrapper not required on Linux.
419    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// ----- macOS -----
502
503#[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, // KeepAlive is enough on launchd
528    });
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    // try user domain too
574    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
608// ----- Windows -----
609
610
611fn 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
676// ----- run-supervised + instance lock + panic hook -----
677
678/// Hold an exclusive lock on `{enroll-state}/node.instance.lock`.
679pub 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) // deny share: second open fails while held
694            .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
745/// Parent process: respawn `current_exe` with `child_args` until forever.
746///
747/// Child stdout/stderr go to files under the enroll-state (or a temp dir) so a
748/// headless Windows spawn never inherits invalid console handles (which made
749/// `println!`/`eprintln!` panic). Interactive CLI never enters this path.
750pub 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                // Last resort: inherit (Unix) or null (Windows) — never panic.
783                #[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        // Refresh sidecar .env (MAIL4AGENT_BIND from --m4a-bind / env) then apply — WMI passes no env.
799        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        // SAFETY: single-threaded before spawn loop; sets log path for parent.
875        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
951/// Parse `install|uninstall|service-status|service-restart|run-supervised`.
952/// Returns `true` if handled (caller should return).
953pub 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}