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}