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