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