Skip to main content

agentmux_launcher/
host_spawn.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::logging::log;
5
6/// Background mode without a tray icon is a resident process nobody can see
7/// or quit. When the tray was requested but failed to start, take both
8/// switches back out of the host's environment, whether they came from the
9/// launcher's own env (the setting) or from `--background`. Must run after
10/// every `.env`/`.envs` that could set them: later `Command` env calls win.
11fn drop_background_if_no_tray(cmd: &mut tokio::process::Command, tray_unavailable: bool) {
12    if tray_unavailable {
13        cmd.env_remove("AGENTMUX_BACKGROUND_SERVICE").env_remove("AGENTMUX_TRAY");
14    }
15}
16
17/// Tell the host to hold its "main" window hidden, or make sure it is not
18/// told so by an inherited env.
19fn set_start_hidden(cmd: &mut tokio::process::Command) {
20    if start_hidden(crate::autostart::login_start(), crate::tray::unavailable()) {
21        cmd.env("AGENTMUX_START_HIDDEN", "1");
22    } else {
23        cmd.env_remove("AGENTMUX_START_HIDDEN");
24    }
25}
26
27/// Set once the first host has been spawned, hidden or not.
28static FIRST_HOST_SPAWNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
29
30/// Should this host hold its "main" window hidden
31/// (`SPEC_START_WITH_OS_2026_09_25.md` §3.3)? Only for a login start, only when
32/// the tray is up (no tray means a normal window, never an invisible process),
33/// and only for the first host: a host restarted after a crash must come back
34/// visible, since the user may already have been using it.
35fn start_hidden(login_start: bool, tray_unavailable: bool) -> bool {
36    let first = !FIRST_HOST_SPAWNED.swap(true, std::sync::atomic::Ordering::SeqCst);
37    start_hidden_decision(login_start, tray_unavailable, first)
38}
39
40fn start_hidden_decision(login_start: bool, tray_unavailable: bool, first_host: bool) -> bool {
41    login_start && !tray_unavailable && first_host
42}
43
44/// Spawn the CEF host suspended, assign it to the launcher's Job Object, and
45/// resume it. Returns the running child, or `None` if any step failed — the
46/// caller decides (fatal on first launch, give-up on a restart). `splash_event`
47/// is passed on every launch — including restarts — so a relaunched host can
48/// still dismiss a splash left pending by a host that crashed pre-first-frame.
49/// `disable_gpu` is the retry ladder's rung-2 degraded mode (spec §7): when set
50/// the host is launched with `--disable-gpu` (software rendering).
51#[cfg(target_os = "windows")]
52pub(crate) fn spawn_host_supervised(
53    real_exe: &std::path::Path,
54    args: &[String],
55    srv: &crate::srv_spawner::SrvSpawnResult,
56    host_env: &[(&'static str, std::ffi::OsString)],
57    pipe_path: &str,
58    job_present: bool,
59    job_handle: windows_sys::Win32::Foundation::HANDLE,
60    splash_event: Option<&str>,
61    disable_gpu: bool,
62) -> Option<tokio::process::Child> {
63    use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
64
65    // The launcher's resolved `real_exe` lives in `runtime/`; pass its
66    // parent dir as AGENTMUX_HOME so the host can anchor asset lookups
67    // (frontend/index.html, etc.) on something stable rather than
68    // `std::env::current_exe()`. Windows's GetModuleFileName keeps
69    // returning the original load-time path even after the parent dir
70    // is renamed or unlinked out from under it — the 2026-05-28
71    // incident pattern. AGENTMUX_HOME is whatever path *we* (the
72    // launcher) successfully resolved real_exe through, so it always
73    // points at the runtime dir that actually contains the binaries.
74    // See docs/retro/retro-portable-rm-running-install-2026-05-28.md.
75    let host_runtime_dir = real_exe.parent().map(|p| p.to_path_buf());
76
77    let mut host_cmd = tokio::process::Command::new(real_exe);
78    host_cmd
79        .args(args)
80        .env("AGENTMUX_BACKEND_WS", &srv.ws_endpoint)
81        .env("AGENTMUX_BACKEND_WEB", &srv.web_endpoint)
82        .env("AGENTMUX_BACKEND_PID", srv.pid.to_string())
83        .env("AGENTMUX_PENDING_MIGRATIONS", srv.pending_migrations.to_string())
84        .env("AGENTMUX_AUTH_KEY", &srv.auth_key)
85        .env("AGENTMUX_HOST_REG_SECRET", &srv.host_reg_secret)
86        .env("AGENTMUX_INSTANCE_ID", &srv.instance_id)
87        // Parent-identity stamp, as on Unix: lets a dev-build host verify
88        // this hand-off is from its real parent THIS run and adopt our srv.
89        // Without it a Windows `task dev` host ignored the env hand-off and
90        // spawned a second srv on the same data dir (#3868 follow-up). See
91        // crates/cef/src/lib.rs::launcher_is_genuine_parent.
92        .env("AGENTMUX_LAUNCHER_PID", std::process::id().to_string())
93        .envs(host_env.iter().cloned())
94        // Auto-start (issue #2977 WS2): translate the launcher's own
95        // `--background` flag into the env the HOST actually reads. The flag
96        // is forwarded to the host in `args` too, but nothing there consumes
97        // it — `AGENTMUX_BACKGROUND_SERVICE` is the real switch, so without
98        // this an auto-started instance would open a normal foreground window
99        // and still quit on last-window-close (Codex P1 on PR #2999).
100        // `background_env_for` also sets `AGENTMUX_TRAY`; see its doc for why
101        // the indicator is not optional in this particular context.
102        .envs(crate::autostart::background_env_for(args))
103        // Lets the host run launcher CLI verbs (`--autostart-status` etc.)
104        // for the Settings UI. SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md §4.1.
105        .envs(launcher_exe_env())
106        .env("AGENTMUX_LAUNCHER_PIPE", pipe_path)
107        // Explicit stdio instead of inheriting the launcher's own —
108        // `CreateProcess(..., CREATE_SUSPENDED, ...)` for this GUI-subsystem
109        // target fails outright with ERROR_NOT_SUPPORTED (os error 50) when
110        // it inherits stdio handles that trace back through an MSYS/Git-Bash
111        // process chain (e.g. this launcher itself spawned from an agent's
112        // own Bash tool call) — confirmed by direct experiment: the same
113        // binary starts cleanly when given real Win32 file handles instead
114        // of inherited MSYS ones. `Stdio::null()`, not `::piped()`: the host
115        // has no stdout/stderr protocol the launcher parses (contrast
116        // `srv_spawner`'s piped handles, which ARE consumed — the ESTART
117        // readiness signal and log forwarding) — piping without ever
118        // reading would risk a silent hang if the host wrote enough output
119        // to fill the pipe buffer. See docs/retro/retro-cef-host-spawn-
120        // fails-under-inherited-nonstandard-stdio-2026-08-17.md.
121        .stdin(std::process::Stdio::null())
122        .stdout(std::process::Stdio::null())
123        .stderr(std::process::Stdio::null())
124        .creation_flags(CREATE_SUSPENDED)
125        .kill_on_drop(false); // J0 handles cleanup.
126    drop_background_if_no_tray(&mut host_cmd, crate::tray::unavailable());
127    set_start_hidden(&mut host_cmd);
128    if let Some(dir) = host_runtime_dir {
129        host_cmd.env("AGENTMUX_HOME", dir);
130    }
131    if let Some(name) = splash_event {
132        host_cmd.env("AGENTMUX_SPLASH_EVENT", name);
133    }
134    // Retry-ladder rung 2 (spec §7): software rendering — no GPU process to
135    // crash. A Chromium switch the host forwards to CEF.
136    if disable_gpu {
137        host_cmd.arg("--disable-gpu");
138    }
139
140    let mut host_child = match host_cmd.spawn() {
141        Ok(c) => c,
142        Err(e) => {
143            log(&format!("failed to spawn CEF host: {}", e));
144            return None;
145        }
146    };
147    let host_pid = host_child.id().unwrap_or(0);
148    log(&format!("spawned CEF host pid={} (suspended)", host_pid));
149
150    // Assign to J0 BEFORE resuming so CEF render children inherit the job.
151    if job_present && host_pid != 0 {
152        match crate::srv_spawner::assign_pid_to_job(host_pid, job_handle) {
153            Ok(()) => log(&format!(
154                "Job Object assigned to host pid={}, KILL_ON_JOB_CLOSE active",
155                host_pid
156            )),
157            Err(e) => log(&format!(
158                "WARN: AssignProcessToJobObject(host) failed: {} — host children may escape job",
159                e
160            )),
161        }
162    }
163
164    // Resume the suspended main thread.
165    if let Err(e) = agentmux_common::win32::resume_main_thread(host_pid) {
166        log(&format!("failed to resume host pid={}: {}", host_pid, e));
167        let _ = host_child.start_kill();
168        return None;
169    }
170    Some(host_child)
171}
172
173/// Spawn the CEF host on Unix with the srv endpoints + canonical
174/// data-dir env handed off, mirroring `spawn_host_supervised` minus the
175/// Windows-only machinery (Job Object assignment, CREATE_SUSPENDED /
176/// resume, named-pipe handle, splash event). `disable_gpu` is the retry
177/// ladder's degraded rung (software rendering). Returns the running
178/// child or `None` if spawn failed.
179///
180/// AGENTMUX_HOME is intentionally NOT set: on the flat dev layout the
181/// host's `current_exe().parent()` fallback resolves to the same dir the
182/// launcher would export, so omitting it keeps asset + framework lookup
183/// byte-identical to the legacy direct-invoke (`task dev:standalone`)
184/// path. Phase 2 will set it once the production runtime/ layout lands
185/// on macOS.
186#[cfg(not(target_os = "windows"))]
187pub(crate) fn spawn_host_unix(
188    real_exe: &std::path::Path,
189    args: &[String],
190    srv: &crate::srv_spawner::SrvSpawnResult,
191    host_env: &[(&'static str, std::ffi::OsString)],
192    disable_gpu: bool,
193) -> Option<tokio::process::Child> {
194    let mut host_cmd = tokio::process::Command::new(real_exe);
195    host_cmd
196        .args(args)
197        .env("AGENTMUX_BACKEND_WS", &srv.ws_endpoint)
198        .env("AGENTMUX_BACKEND_WEB", &srv.web_endpoint)
199        .env("AGENTMUX_BACKEND_PID", srv.pid.to_string())
200        .env("AGENTMUX_PENDING_MIGRATIONS", srv.pending_migrations.to_string())
201        .env("AGENTMUX_AUTH_KEY", &srv.auth_key)
202        .env("AGENTMUX_HOST_REG_SECRET", &srv.host_reg_secret)
203        .env("AGENTMUX_INSTANCE_ID", &srv.instance_id)
204        // Parent-identity stamp: our pid == the host's getppid (we spawn it
205        // directly). A dev-build host normally ignores AGENTMUX_BACKEND_WS
206        // (it could be a stale value inherited from a parent agentmux pane);
207        // this lets the host verify the hand-off is genuinely ours THIS run
208        // and adopt our launcher-owned srv instead of double-spawning. See
209        // crates/cef/src/main.rs::launcher_is_genuine_parent.
210        .env("AGENTMUX_LAUNCHER_PID", std::process::id().to_string())
211        .envs(host_env.iter().cloned())
212        // Auto-start (issue #2977 WS2) — same translation as the Windows
213        // spawn path. macOS and Linux are the platforms whose generated
214        // artifacts (LaunchAgent, XDG .desktop) actually pass `--background`,
215        // so omitting it here left two of the three shipped platforms with an
216        // inert flag (ReAgent P1 on PR #2999, catching that my first fix for
217        // this was wired into the Windows spawn only).
218        .envs(crate::autostart::background_env_for(args))
219        // Lets the host run launcher CLI verbs (`--autostart-status` etc.)
220        // for the Settings UI. SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md §4.1.
221        .envs(launcher_exe_env())
222        // We reap children ourselves on shutdown (SIGTERM, then SIGKILL
223        // backstop) — kill_on_drop would SIGKILL the host the moment the
224        // Child is dropped, robbing CEF of the chance to reap its render
225        // subprocesses cleanly.
226        .kill_on_drop(false)
227        // SPEC_LAUNCHER_TEARDOWN_BACKSTOP_2026_07_11 unix parity — make the
228        // host its own process-group leader (pgid == its own pid) instead of
229        // inheriting the launcher's group. This is the prerequisite for
230        // `kill_process_group_forcefully`'s `kill(-pgid, SIGKILL)`: without
231        // an explicit group, the host and any CEF subprocesses it forks
232        // (renderer/GPU/zygote) sit in the launcher's OWN group, and a
233        // group-kill from the teardown backstop would take the launcher
234        // down with it mid-cleanup. Scoped exactly to what this call spawns
235        // — the same blast-radius bound Windows gets for free from owning
236        // its Job Object.
237        .process_group(0);
238    drop_background_if_no_tray(&mut host_cmd, crate::tray::unavailable());
239    set_start_hidden(&mut host_cmd);
240    if disable_gpu {
241        host_cmd.arg("--disable-gpu");
242    }
243    // Linux process-tree reap: PR_SET_PDEATHSIG asks the kernel to SIGKILL
244    // this child the moment its parent (the launcher) dies, even abnormally
245    // (e.g. launcher panic, OOM, SIGKILL from outside). This is the Linux
246    // analogue of Windows' Job Object KILL_ON_JOB_CLOSE that A0 wires up
247    // (SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05 §3 step 3 and
248    // §A1.4). Without it, a launcher crash orphans the CEF host onto PID 1
249    // and the user is left with a zombie tree until manual cleanup.
250    //
251    // The closure runs between fork() and execve() in the child; per
252    // POSIX it MUST NOT allocate, take locks, or call async-signal-unsafe
253    // functions. `prctl(2)` is async-signal-safe. macOS lacks prctl, so
254    // this is gated to Linux; macOS uses NSTask's auto-reap behavior.
255    #[cfg(target_os = "linux")]
256    {
257        // Safety: prctl is async-signal-safe. No allocation, no syscalls
258        // beyond prctl itself. Errors from prctl are non-fatal — the host
259        // still spawns; we just lose the auto-reap guarantee.
260        unsafe {
261            host_cmd.pre_exec(|| {
262                libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL, 0, 0, 0);
263                Ok(())
264            });
265        }
266    }
267    match host_cmd.spawn() {
268        Ok(c) => {
269            let pid = c.id().unwrap_or(0);
270            log(&format!("spawned CEF host pid={} (unix)", pid));
271            Some(c)
272        }
273        Err(e) => {
274            log(&format!("failed to spawn CEF host: {}", e));
275            None
276        }
277    }
278}
279
280/// Send SIGTERM to a child so it can shut down gracefully — for the CEF
281/// host that means reaping its render subprocesses (a tokio `start_kill`
282/// SIGKILL would orphan them); for srv it's a clean shutdown. No-op if the
283/// child has already exited (its pid may have been reaped). Best-effort:
284/// the SIGKILL grace-window backstop in `run_unix` catches anything that
285/// ignores SIGTERM.
286#[cfg(not(target_os = "windows"))]
287pub(crate) fn terminate_child_gracefully(child: &tokio::process::Child) {
288    if let Some(pid) = child.id() {
289        // SAFETY: kill(2) with a process-scoped pid + a constant signal —
290        // no memory is touched. A stale pid just returns ESRCH (ignored).
291        unsafe {
292            libc::kill(pid as libc::pid_t, libc::SIGTERM);
293        }
294    }
295}
296
297/// Send `signal` to an entire process group in one shot (negated pid — see
298/// the two public wrappers below for why this targets a whole group, not
299/// just one process). Shared by both so the return-value handling — a
300/// silently-ignored failure would otherwise let a genuinely-failed kill
301/// (anything but the expected "already empty" ESRCH) read as a successful
302/// teardown — lives in exactly one place. Logs on any failure OTHER than
303/// ESRCH (the routine no-op case: the group was already empty, e.g. a prior
304/// SIGTERM's own graceful shutdown already reaped it).
305#[cfg(not(target_os = "windows"))]
306fn kill_process_group(child: &tokio::process::Child, signal: libc::c_int, signal_name: &str) {
307    if let Some(pid) = child.id() {
308        kill_group(pid, signal, signal_name);
309    }
310}
311
312/// The signal itself goes through `agentmux_common::process::signal_process_group`,
313/// which refuses a pgid of 0 or 1 (the launcher's own group, or the
314/// broadcast) rather than signalling it.
315#[cfg(not(target_os = "windows"))]
316fn kill_group(pid: u32, signal: libc::c_int, signal_name: &str) {
317    if let Err(err) = agentmux_common::process::signal_process_group(pid, signal) {
318        if err.raw_os_error() != Some(libc::ESRCH) {
319            crate::logging::log(&format!(
320                "WARN: kill(-{}, {}) failed: {}",
321                pid, signal_name, err
322            ));
323        }
324    }
325}
326
327/// SIGTERM an entire process group, before the harder
328/// `kill_process_group_forcefully` follows. The teardown backstop uses it
329/// on the host's group; srv is never group-signalled first, since its own
330/// SIGTERM/stdin-EOF shutdown closes its agents (`upgrade::quiesce_srv`).
331#[cfg(not(target_os = "windows"))]
332pub(crate) fn kill_process_group_gracefully(child: &tokio::process::Child) {
333    kill_process_group(child, libc::SIGTERM, "SIGTERM");
334}
335
336/// SIGKILL an entire process group in one shot — the unix analogue of
337/// Windows' `TerminateJobObject`. `child` must have been spawned with
338/// `.process_group(0)` (both `spawn_host_unix` and `srv_spawner::spawn_srv`
339/// do this) so its pgid equals its own pid; negating the pid targets the
340/// whole group instead of just the one process, reaping any descendants
341/// (e.g. CEF's forked renderer/GPU subprocesses) the launcher never tracks
342/// individually. Used by the teardown backstop
343/// (SPEC_LAUNCHER_TEARDOWN_BACKSTOP_2026_07_11) as the hard backstop after
344/// `kill_process_group_gracefully`'s brief grace window — reserved for
345/// whatever a graceful SIGTERM didn't reap (a wedged-or-dead srv, or shells
346/// its own `stop_all()` couldn't finish in time). No-op if the child has
347/// already exited (its pid may have been reaped; a stale pid's negated kill
348/// returns ESRCH, ignored).
349#[cfg(not(target_os = "windows"))]
350pub(crate) fn kill_process_group_forcefully(child: &tokio::process::Child) {
351    kill_process_group(child, libc::SIGKILL, "SIGKILL");
352}
353
354/// [`kill_process_group_forcefully`] by pgid, for a child already reaped
355/// (whose `id()` is gone): srv's group after srv was force-killed.
356#[cfg(not(target_os = "windows"))]
357pub(crate) fn kill_group_forcefully(pgid: u32) {
358    kill_group(pgid, libc::SIGKILL, "SIGKILL");
359}
360
361#[cfg(all(test, not(target_os = "windows")))]
362mod tests {
363    //! `kill_process_group_forcefully` and the `.process_group(0)` calls in
364    //! this file / `srv_spawner.rs` are the prerequisite the SPEC_LAUNCHER_
365    //! TEARDOWN_BACKSTOP_2026_07_11 unix teardown backstop depends on — a
366    //! survey ahead of this change (issue #2188) confirmed NO unix child of
367    //! the launcher was ever placed in its own process group, so
368    //! `kill(-pgid, SIGKILL)` had no safe target. These exercise the two
369    //! real invariants against actually-spawned processes rather than
370    //! asserting on the builder call alone, since a wrong signature (e.g.
371    //! `.process_group(pid)` instead of `.process_group(0)`) would compile
372    //! fine and silently fail to create a new group.
373
374    use super::{kill_process_group_forcefully, kill_process_group_gracefully};
375
376    /// `.process_group(0)` makes the spawned child its own group leader —
377    /// pgid must equal its own pid, not the test process's pgid.
378    #[tokio::test]
379    async fn process_group_zero_makes_the_child_its_own_group_leader() {
380        let mut cmd = tokio::process::Command::new("sleep");
381        cmd.arg("5").kill_on_drop(true).process_group(0);
382        let mut child = cmd.spawn().expect("spawn sleep");
383        let pid = child.id().expect("child has a pid") as libc::pid_t;
384
385        // SAFETY: getpgid with process-scoped pids; no memory touched.
386        let pgid = unsafe { libc::getpgid(pid) };
387        let our_pgid = unsafe { libc::getpgid(0) };
388        assert_eq!(pgid, pid, "process_group(0) must make pgid == pid");
389        // The behavioral half of the invariant (reagent P1, PR #2200): the
390        // child must NOT be in our own group, since that's exactly what
391        // makes it stop receiving a group-directed signal (e.g. a terminal
392        // Ctrl+C, which the shell delivers to its whole foreground group) —
393        // the tradeoff this change makes host/srv exit via the launcher's
394        // explicit terminate_child_gracefully instead of direct propagation.
395        assert_ne!(
396            pgid, our_pgid,
397            "process_group(0) must isolate the child from our own group, \
398             or a group-directed signal (e.g. terminal Ctrl+C) would still reach it"
399        );
400
401        let _ = child.kill().await;
402    }
403
404    /// The kill actually reaches the process (signal-terminated, not a
405    /// silent no-op) — the behavioral half of the prerequisite, mirroring
406    /// what the teardown backstop's live-fire path depends on.
407    #[tokio::test]
408    async fn kill_process_group_forcefully_terminates_a_real_child() {
409        use std::os::unix::process::ExitStatusExt;
410
411        let mut cmd = tokio::process::Command::new("sleep");
412        cmd.arg("5").kill_on_drop(false).process_group(0);
413        let mut child = cmd.spawn().expect("spawn sleep");
414
415        kill_process_group_forcefully(&child);
416
417        let status = tokio::time::timeout(std::time::Duration::from_secs(2), child.wait())
418            .await
419            .expect("child did not exit after group kill")
420            .expect("wait succeeded");
421        assert_eq!(status.signal(), Some(libc::SIGKILL));
422    }
423
424    /// A child that already exited (pid reaped) must not panic or error —
425    /// the teardown arm calls this unconditionally on both host and srv
426    /// without checking liveness first.
427    #[tokio::test]
428    async fn kill_process_group_forcefully_is_a_noop_on_an_already_exited_child() {
429        let mut cmd = tokio::process::Command::new("true");
430        cmd.kill_on_drop(false).process_group(0);
431        let mut child = cmd.spawn().expect("spawn true");
432        let _ = child.wait().await; // let it exit and get reaped
433
434        kill_process_group_forcefully(&child); // must not panic
435    }
436
437    /// `kill_process_group_gracefully` sends SIGTERM, not SIGKILL — the
438    /// half of the two-phase teardown a process WITH a signal handler (srv)
439    /// gets a real chance to act on.
440    #[tokio::test]
441    async fn kill_process_group_gracefully_sends_sigterm_not_sigkill() {
442        use std::os::unix::process::ExitStatusExt;
443
444        let mut cmd = tokio::process::Command::new("sleep");
445        cmd.arg("5").kill_on_drop(false).process_group(0);
446        let mut child = cmd.spawn().expect("spawn sleep");
447
448        kill_process_group_gracefully(&child);
449
450        let status = tokio::time::timeout(std::time::Duration::from_secs(2), child.wait())
451            .await
452            .expect("child did not exit after graceful group kill")
453            .expect("wait succeeded");
454        assert_eq!(status.signal(), Some(libc::SIGTERM));
455    }
456
457    /// Same no-op-on-already-exited guarantee as the forceful variant — the
458    /// teardown arm calls both unconditionally.
459    #[tokio::test]
460    async fn kill_process_group_gracefully_is_a_noop_on_an_already_exited_child() {
461        let mut cmd = tokio::process::Command::new("true");
462        cmd.kill_on_drop(false).process_group(0);
463        let mut child = cmd.spawn().expect("spawn true");
464        let _ = child.wait().await; // let it exit and get reaped
465
466        kill_process_group_gracefully(&child); // must not panic
467    }
468}
469
470/// `AGENTMUX_LAUNCHER_EXE` for the host: this launcher's own executable, so
471/// the host can invoke launcher CLI verbs (`--autostart-status`,
472/// `--enable-autostart`, `--disable-autostart`) on the user's behalf. Empty
473/// when `current_exe` fails — the host then reports auto-start as unavailable.
474fn launcher_exe_env() -> Vec<(&'static str, std::ffi::OsString)> {
475    std::env::current_exe()
476        .map(|p| vec![("AGENTMUX_LAUNCHER_EXE", p.into_os_string())])
477        .unwrap_or_default()
478}
479
480#[cfg(test)]
481mod tray_fallback_tests {
482    fn host_env(tray_unavailable: bool, args: &[String]) -> Vec<(String, Option<String>)> {
483        let mut cmd = tokio::process::Command::new("agentmux-host");
484        cmd.env("AGENTMUX_BACKGROUND_SERVICE", "1")
485            .env("AGENTMUX_TRAY", "1")
486            .envs(crate::autostart::background_env_for(args));
487        super::drop_background_if_no_tray(&mut cmd, tray_unavailable);
488        cmd.as_std()
489            .get_envs()
490            .filter(|(k, _)| k.to_str().is_some_and(|k| k == "AGENTMUX_BACKGROUND_SERVICE" || k == "AGENTMUX_TRAY"))
491            .map(|(k, v)| (k.to_string_lossy().into_owned(), v.map(|v| v.to_string_lossy().into_owned())))
492            .collect()
493    }
494
495    /// A tray that failed to start must not leave the host in background
496    /// mode: closing the last window would leave a process with no icon.
497    /// Covers both sources, the launcher's env (the setting) and `--background`.
498    #[test]
499    fn a_failed_tray_removes_background_mode_from_the_host() {
500        let args = vec!["agentmux".to_string(), crate::autostart::BACKGROUND_FLAG.to_string()];
501        let env = host_env(true, &args);
502        assert_eq!(env.len(), 2, "{env:?}");
503        assert!(env.iter().all(|(_, v)| v.is_none()), "both switches removed: {env:?}");
504    }
505
506    #[test]
507    fn a_working_tray_leaves_background_mode_alone() {
508        let env = host_env(false, &["agentmux".to_string()]);
509        assert_eq!(env.len(), 2, "{env:?}");
510        assert!(env.iter().all(|(_, v)| v.is_some()), "{env:?}");
511    }
512
513    #[test]
514    fn only_the_first_host_of_a_login_start_with_a_tray_starts_hidden() {
515        assert!(super::start_hidden_decision(true, false, true));
516        assert!(!super::start_hidden_decision(false, false, true), "a manual start shows its window");
517        assert!(!super::start_hidden_decision(true, true, true), "no tray: a window, never an invisible process");
518        assert!(!super::start_hidden_decision(true, false, false), "a restarted host comes back visible");
519    }
520}