Skip to main content

agentmux_launcher/autostart/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Auto-start-at-login registration — issue #2977 Workstream 2,
5//! `docs/specs/SPEC_TRAY_OPTIONAL_BACKGROUND_SERVICE_2026_09_04.md` §4.
6//!
7//! ## Opt-in, off by default, and separate from "close to tray"
8//!
9//! Nothing here runs unless the user explicitly asks for it. The issue is
10//! emphatic that these are two different decisions: "a user can have
11//! 'close-to-tray keeps it running for this session' without 'starts
12//! automatically at every login' being bundled into the same toggle". So
13//! auto-start has its own state, its own enable/disable, and is never
14//! implied by `AGENTMUX_TRAY` or `AGENTMUX_BACKGROUND_SERVICE`.
15//!
16//! ## Mechanism per platform, and what was rejected
17//!
18//! - **Windows — Scheduled Task, `LogonType=InteractiveToken`.** Not a
19//!   Windows Service (needs admin to register, and Session-0 isolation means
20//!   it structurally cannot show a tray icon), and not a bare `Run` registry
21//!   key (no crash supervision, no delay control).
22//! - **macOS — LaunchAgent plist. This DEVIATES from the governing spec, and
23//!   the deviation has a user-visible consequence.**
24//!   `SPEC_TRAY_OPTIONAL_BACKGROUND_SERVICE_2026_09_04.md` §4 calls
25//!   `SMAppService` "unambiguously current best practice" and notes it is the
26//!   only path that surfaces correctly in **System Settings → Login Items &
27//!   Extensions**. Writing the LaunchAgent directly means the entry may not
28//!   appear there the way a user expects, which matters more than usual here:
29//!   Workstream 4 requires that a user be able to *see* and remove a
30//!   background service, and Login Items is where they will look.
31//!   `SMAppService` is only callable through its ObjC interface from inside a
32//!   **bundled, signed** app, so adopting it means objc2 bindings plus a
33//!   bundle identifier that matches the registered agent — real work that
34//!   cannot be written blind or verified on a non-macOS machine. Recorded as
35//!   a known gap rather than pretended away: macOS auto-start should move to
36//!   `SMAppService` before it is offered to users. (ReAgent P1 on PR #2999.)
37//! - **Linux — XDG autostart `.desktop`.** `systemd --user` is deliberately
38//!   NOT the default: surviving logout needs `loginctl enable-linger`, which
39//!   is a separate, consequential decision the issue says must never be
40//!   silently enabled.
41//!
42//! ## Uninstall is a first-class operation (Workstream 4)
43//!
44//! WS4 requires "one complete, independently-testable uninstall path that
45//! actually removes the LaunchAgent/Scheduled-Task/autostart artifact — not
46//! just closes the window". `disable()` is that path, and `is_enabled()`
47//! makes it independently checkable: the pair is the whole contract, and
48//! `disable()` is idempotent so an uninstaller can call it unconditionally.
49//!
50//! ## Structure
51//!
52//! The artifact *content* (Task Scheduler XML, plist, desktop entry) is
53//! generated by pure functions that take an exe path and return a string.
54//! That keeps the part most likely to be subtly wrong — escaping, schema
55//! fields, the `InteractiveToken` logon type — unit-testable without
56//! touching the machine's real login configuration.
57
58#[cfg(windows)]
59use agentmux_common::win32::NoWindow;
60use std::path::Path;
61
62/// The flag every generated artifact passes to the launcher.
63///
64/// This is the ONLY thing that makes an auto-started instance behave like a
65/// background service. Without it being interpreted, an auto-start entry
66/// would just open an ordinary foreground window at login and still quit when
67/// the user closed it — i.e. the feature would silently not work (Codex P1 on
68/// PR #2999).
69pub const BACKGROUND_FLAG: &str = "--background";
70
71/// Did the launcher get started in background mode?
72///
73/// Pure so the argv parsing is testable. Callers turn this into the env the
74/// host actually reads (`AGENTMUX_BACKGROUND_SERVICE`, plus `AGENTMUX_TRAY` —
75/// see `background_env_for`).
76pub fn background_requested(args: &[String]) -> bool {
77    args.iter().any(|a| a == BACKGROUND_FLAG)
78}
79
80/// Was this process started by a login entry? Every artifact passes
81/// `--background`, and nothing else does.
82pub fn login_start() -> bool {
83    background_requested(&std::env::args().collect::<Vec<_>>())
84}
85
86/// A launcher that finds AgentMux already running forwards `open_new_window`
87/// to it, so a double-click opens a window. A login entry firing while the
88/// user already has AgentMux open must not (`SPEC_START_WITH_OS_2026_09_25.md`
89/// §3.6): it just exits.
90pub fn second_instance_opens_window(args: &[String]) -> bool {
91    !background_requested(args)
92}
93
94/// Environment the host must receive when the launcher was started with
95/// `--background`.
96///
97/// Sets BOTH switches deliberately. Background mode without a tray icon is
98/// the precise anti-pattern Workstream 4 exists to prevent: a process that
99/// starts itself at login, keeps running with no window, and offers the user
100/// no visible indicator that it is there or any way to stop it. Auto-start is
101/// the one context where the indicator is not optional, so `--background`
102/// implies the tray rather than leaving them independently settable.
103///
104/// Returns pairs rather than mutating a `Command` so the mapping is testable.
105pub fn background_env_for(args: &[String]) -> Vec<(&'static str, &'static str)> {
106    if background_requested(args) {
107        vec![
108            ("AGENTMUX_BACKGROUND_SERVICE", "1"),
109            ("AGENTMUX_TRAY", "1"),
110        ]
111    } else {
112        Vec::new()
113    }
114}
115
116/// Identifier used for the artifact on every platform: the Scheduled Task
117/// name, the LaunchAgent label's suffix, and the `.desktop` filename stem.
118/// Stable, because `disable()` has to find what `enable()` created —
119/// including across upgrades, so it deliberately carries no version.
120#[cfg(any(target_os = "windows", target_os = "linux", test))]
121pub const AUTOSTART_ID: &str = "AgentMux";
122
123/// Reverse-DNS label for the macOS LaunchAgent, which requires that form.
124#[cfg(any(not(target_os = "windows"), test))]
125pub const MACOS_LABEL: &str = "com.agentmux.launcher";
126
127/// Windows Task Scheduler registration XML.
128///
129/// `LogonType=InteractiveToken` is the load-bearing detail (issue WS2): it
130/// runs the task as the logged-in user *in their interactive session*, which
131/// is what lets the process own a tray icon and later spawn visible windows.
132/// `RunLevel=LeastPrivilege` keeps it out of elevation prompts — auto-start
133/// must not require admin.
134///
135/// `<Delay>PT30S</Delay>` staggers startup behind the desktop's own login
136/// work; a background service racing Explorer for disk on every boot is a
137/// bad citizen, and 30s is invisible for something meant to run all day.
138///
139/// `DisallowStartIfOnBatteries`/`StopIfGoingOnBatteries` are both false
140/// deliberately: the defaults are *true*, which would make a laptop user's
141/// background service silently not start, or die mid-turn on unplug — the
142/// exact "is it actually running?" unreliability WS4 warns about.
143#[cfg(any(target_os = "windows", test))]
144pub fn scheduled_task_xml(exe: &Path, channel: &str) -> String {
145    let exe = xml_escape(&exe.display().to_string());
146    let channel = xml_escape(channel);
147    format!(
148        r#"<?xml version="1.0" encoding="UTF-16"?>
149<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
150  <RegistrationInfo>
151    <Description>Starts AgentMux in the background at logon. Created by AgentMux; remove it by turning off auto-start in AgentMux, or with: schtasks /Delete /TN "{id}" /F {marker}{channel}]</Description>
152    <URI>\{id}</URI>
153  </RegistrationInfo>
154  <Triggers>
155    <LogonTrigger>
156      <Enabled>true</Enabled>
157      <Delay>PT30S</Delay>
158    </LogonTrigger>
159  </Triggers>
160  <Principals>
161    <Principal id="Author">
162      <LogonType>InteractiveToken</LogonType>
163      <RunLevel>LeastPrivilege</RunLevel>
164    </Principal>
165  </Principals>
166  <Settings>
167    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
168    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
169    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
170    <AllowHardTerminate>false</AllowHardTerminate>
171    <StartWhenAvailable>false</StartWhenAvailable>
172    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
173    <IdleSettings>
174      <StopOnIdleEnd>false</StopOnIdleEnd>
175      <RestartOnIdle>false</RestartOnIdle>
176    </IdleSettings>
177    <AllowStartOnDemand>true</AllowStartOnDemand>
178    <Enabled>true</Enabled>
179    <Hidden>false</Hidden>
180    <RunOnlyIfIdle>false</RunOnlyIfIdle>
181    <WakeToRun>false</WakeToRun>
182    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
183    <Priority>7</Priority>
184  </Settings>
185  <Actions Context="Author">
186    <Exec>
187      <Command>"{exe}"</Command>
188      <Arguments>--background</Arguments>
189    </Exec>
190  </Actions>
191</Task>
192"#,
193        id = AUTOSTART_ID,
194        exe = exe,
195        marker = TASK_CHANNEL_MARKER,
196        channel = channel,
197    )
198}
199
200/// Marks the owning channel inside the Task's `<Description>`, the only
201/// free-text field Task Scheduler keeps verbatim.
202const TASK_CHANNEL_MARKER: &str = "[agentmux-channel=";
203
204/// macOS LaunchAgent plist.
205///
206/// `RunAtLoad` starts it at login. `KeepAlive` is deliberately **false**: the
207/// launcher already supervises `srv`/`host` and owns its own restart policy,
208/// so letting `launchd` also restart it would give two supervisors fighting
209/// over the same process — and would make a user-requested Quit from the tray
210/// come straight back, which is worse than useless.
211#[cfg(any(not(target_os = "windows"), test))]
212pub fn launch_agent_plist(exe: &Path, channel: &str) -> String {
213    let exe = xml_escape(&exe.display().to_string());
214    let channel = xml_escape(channel);
215    format!(
216        r#"<?xml version="1.0" encoding="UTF-8"?>
217<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
218<plist version="1.0">
219<dict>
220    <key>Label</key>
221    <string>{label}</string>
222    <key>ProgramArguments</key>
223    <array>
224        <string>{exe}</string>
225        <string>--background</string>
226    </array>
227    <key>RunAtLoad</key>
228    <true/>
229    <key>KeepAlive</key>
230    <false/>
231    <key>ProcessType</key>
232    <string>Interactive</string>
233    <key>{channel_key}</key>
234    <string>{channel}</string>
235</dict>
236</plist>
237"#,
238        label = MACOS_LABEL,
239        exe = exe,
240        channel_key = PLIST_CHANNEL_KEY,
241        channel = channel,
242    )
243}
244
245/// Owning channel, as a plist key launchd ignores.
246const PLIST_CHANNEL_KEY: &str = "AgentMuxChannel";
247
248/// Linux XDG autostart desktop entry.
249///
250/// `X-GNOME-Autostart-enabled=true` is included because some desktops treat a
251/// missing value as disabled. `Terminal=false` keeps it from spawning a
252/// console window on desktops that honor it.
253///
254/// `TryExec` makes the entry inert once the target is gone (app removed,
255/// AppImage deleted): the session skips it instead of failing at every login
256/// (`SPEC_START_WITH_OS_2026_09_25.md` §3.1). `X-GNOME-Autostart-Delay` gives
257/// the tray host time to come up first (§3.4). `X-AgentMux-Channel` records
258/// which build owns the entry (§3.5).
259#[cfg(any(not(target_os = "windows"), test))]
260pub fn xdg_desktop_entry(exe: &Path, channel: &str) -> String {
261    format!(
262        "[Desktop Entry]\n\
263         Type=Application\n\
264         Name=AgentMux\n\
265         Comment=Starts AgentMux in the background at login\n\
266         TryExec={try_exec}\n\
267         Exec=\"{exe}\" {flag}\n\
268         Terminal=false\n\
269         X-GNOME-Autostart-enabled=true\n\
270         X-GNOME-Autostart-Delay=10\n\
271         {channel_key}={channel}\n",
272        try_exec = desktop_string_escape(&exe.display().to_string()),
273        exe = desktop_exec_escape(exe),
274        flag = BACKGROUND_FLAG,
275        channel_key = XDG_CHANNEL_KEY,
276        channel = desktop_string_escape(channel),
277    )
278}
279
280/// Owning channel, as a vendor key (`X-` keys are ignored by the session).
281const XDG_CHANNEL_KEY: &str = "X-AgentMux-Channel";
282
283/// Escape a plain desktop-entry string value (`TryExec`, vendor keys), per the
284/// Desktop Entry spec's `\\`, `\n`, `\t`, `\r` escapes. A raw newline would
285/// end the line and let a path inject keys, as `desktop_exec_escape` explains.
286#[cfg(any(not(target_os = "windows"), test))]
287fn desktop_string_escape(s: &str) -> String {
288    let mut out = String::with_capacity(s.len());
289    for c in s.chars() {
290        match c {
291            '\\' => out.push_str("\\\\"),
292            '\n' => out.push_str("\\n"),
293            '\t' => out.push_str("\\t"),
294            '\r' => out.push_str("\\r"),
295            _ => out.push(c),
296        }
297    }
298    out
299}
300
301fn desktop_string_unescape(s: &str) -> String {
302    let mut out = String::with_capacity(s.len());
303    let mut it = s.chars();
304    while let Some(c) = it.next() {
305        if c != '\\' {
306            out.push(c);
307            continue;
308        }
309        match it.next() {
310            Some('n') => out.push('\n'),
311            Some('t') => out.push('\t'),
312            Some('r') => out.push('\r'),
313            Some('s') => out.push(' '),
314            Some(o) => out.push(o),
315            None => out.push('\\'),
316        }
317    }
318    out
319}
320
321/// Escape a path for use inside a **quoted argument** of a desktop entry's
322/// `Exec=` value.
323///
324/// Stripping newlines is not enough (ReAgent/Codex P2 on PR #2999). All of
325/// these are legal in a Unix path and all of them change how the desktop-entry
326/// parser tokenizes the command:
327///
328/// - `"` terminates the quoted argument — the rest of the path becomes
329///   separate argv entries, so the app is launched with the wrong executable.
330/// - `\`, `` ` ``, `$` are reserved inside quoted arguments per the
331///   Desktop Entry Specification and must be backslash-escaped.
332/// - `%` introduces a **field code** (`%f`, `%U`, …); an unescaped one is
333///   substituted by the launcher. The spec's escape for a literal is `%%`.
334/// - newline/CR would end the `Exec=` line entirely and let the remainder be
335///   parsed as further desktop-entry keys (the original guard, kept).
336///
337/// Same injection class the XML path already handles via `xml_escape`; this
338/// is the desktop-entry grammar's version of it.
339#[cfg(any(not(target_os = "windows"), test))]
340fn desktop_exec_escape(exe: &Path) -> String {
341    let raw = exe.display().to_string();
342    let mut out = String::with_capacity(raw.len());
343    for c in raw.chars() {
344        match c {
345            // Reserved inside a quoted argument — escape with a backslash.
346            '"' | '\\' | '`' | '$' => {
347                out.push('\\');
348                out.push(c);
349            }
350            // Field-code introducer; `%%` is the literal.
351            '%' => out.push_str("%%"),
352            // Would terminate the Exec= line and allow key injection.
353            '\n' | '\r' => {}
354            _ => out.push(c),
355        }
356    }
357    out
358}
359
360/// Minimal XML text escaping for the two generated XML documents.
361///
362/// Only the five predefined entities matter here, and the only untrusted-ish
363/// input is a filesystem path (which can legitimately contain `&` and, on
364/// Unix, `<`/`>`/quotes). Hand-rolled rather than pulling an XML crate into a
365/// binary the codebase deliberately keeps tiny — the same reasoning
366/// `second_instance.rs` gives for hand-rolling its HTTP request.
367fn xml_escape(s: &str) -> String {
368    let mut out = String::with_capacity(s.len());
369    for c in s.chars() {
370        match c {
371            '&' => out.push_str("&amp;"),
372            '<' => out.push_str("&lt;"),
373            '>' => out.push_str("&gt;"),
374            '"' => out.push_str("&quot;"),
375            '\'' => out.push_str("&apos;"),
376            _ => out.push(c),
377        }
378    }
379    out
380}
381
382/// Where the artifact lives, for the platforms that use a plain file.
383/// Not used on Windows, whose artifact lives in the Task Scheduler store
384/// rather than a path we own.
385#[cfg(not(target_os = "windows"))]
386pub fn artifact_path() -> Option<std::path::PathBuf> {
387    #[cfg(target_os = "macos")]
388    {
389        let home = std::env::var("HOME").ok()?;
390        Some(
391            Path::new(&home)
392                .join("Library/LaunchAgents")
393                .join(format!("{}.plist", MACOS_LABEL)),
394        )
395    }
396    #[cfg(target_os = "linux")]
397    {
398        // Honor XDG_CONFIG_HOME; fall back to the spec's default.
399        let base = std::env::var("XDG_CONFIG_HOME")
400            .ok()
401            .filter(|s| !s.is_empty())
402            .unwrap_or_else(|| format!("{}/.config", std::env::var("HOME").unwrap_or_default()));
403        Some(
404            Path::new(&base)
405                .join("autostart")
406                .join(format!("{}.desktop", AUTOSTART_ID.to_lowercase())),
407        )
408    }
409    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
410    {
411        None
412    }
413}
414
415/// A `schtasks` command that never opens a console window.
416///
417/// The launcher is a GUI-subsystem process and `schtasks.exe` is a console
418/// program, so a bare spawn flashes a console over the splash. The start-at-login
419/// reconcile runs `schtasks /Query` on every startup.
420#[cfg(target_os = "windows")]
421fn schtasks_command() -> std::process::Command {
422    let mut cmd = std::process::Command::new("schtasks");
423    cmd.no_window();
424    cmd
425}
426
427/// Is auto-start currently registered?
428///
429/// Half of the WS4 uninstall contract: an uninstall path is only
430/// "independently testable" if something can answer this without trusting
431/// whatever wrote it. So this inspects the real system state (the Task
432/// Scheduler store, or the artifact file) rather than any bookkeeping of our
433/// own.
434pub fn is_enabled() -> bool {
435    #[cfg(target_os = "windows")]
436    {
437        // `schtasks /Query` exits non-zero when the task does not exist.
438        schtasks_command()
439            .args(["/Query", "/TN", AUTOSTART_ID])
440            .stdout(std::process::Stdio::null())
441            .stderr(std::process::Stdio::null())
442            .status()
443            .map(|s| s.success())
444            .unwrap_or(false)
445    }
446    #[cfg(not(target_os = "windows"))]
447    {
448        artifact_path().map(|p| p.exists()).unwrap_or(false)
449    }
450}
451
452/// Register auto-start for `exe`. Idempotent: re-registering an existing
453/// entry replaces it, which is also how an upgrade repoints the artifact at a
454/// new install path.
455pub fn enable(exe: &Path, channel: &str) -> Result<(), String> {
456    #[cfg(target_os = "windows")]
457    {
458        // Register from XML — the only way to set LogonType/battery settings;
459        // the flag-based `schtasks /Create /SC ONLOGON` form cannot express
460        // them. The XML must be UTF-16 per the Task Scheduler schema
461        // declaration, so it is written as UTF-16LE with a BOM.
462        let xml = scheduled_task_xml(exe, channel);
463        let dir = std::env::temp_dir();
464        let path = dir.join(format!("agentmux-autostart-{}.xml", std::process::id()));
465        let mut bytes: Vec<u8> = vec![0xFF, 0xFE]; // UTF-16LE BOM
466        for unit in xml.encode_utf16() {
467            bytes.extend_from_slice(&unit.to_le_bytes());
468        }
469        std::fs::write(&path, &bytes).map_err(|e| format!("write task xml: {}", e))?;
470
471        // Capture output rather than discarding it: schtasks reports schema
472        // and permission problems on stderr, and an autostart registration
473        // that fails with only "exit code 1" is undiagnosable for a user or
474        // in a bug report.
475        let out = schtasks_command()
476            .args(["/Create", "/TN", AUTOSTART_ID, "/XML"])
477            .arg(&path)
478            .arg("/F") // replace an existing registration
479            .output();
480        // Best-effort cleanup; the temp file is harmless if it survives.
481        let _ = std::fs::remove_file(&path);
482
483        match out {
484            Ok(o) if o.status.success() => Ok(()),
485            Ok(o) => Err(format!(
486                "schtasks /Create failed ({}): {}{}",
487                o.status,
488                String::from_utf8_lossy(&o.stderr).trim(),
489                String::from_utf8_lossy(&o.stdout).trim()
490            )),
491            Err(e) => Err(format!("run schtasks: {}", e)),
492        }
493    }
494    #[cfg(not(target_os = "windows"))]
495    {
496        let path = artifact_path().ok_or_else(|| "unsupported platform".to_string())?;
497        let contents = if cfg!(target_os = "macos") {
498            launch_agent_plist(exe, channel)
499        } else {
500            xdg_desktop_entry(exe, channel)
501        };
502        if let Some(parent) = path.parent() {
503            std::fs::create_dir_all(parent)
504                .map_err(|e| format!("create {}: {}", parent.display(), e))?;
505        }
506        std::fs::write(&path, contents).map_err(|e| format!("write {}: {}", path.display(), e))
507    }
508}
509
510/// Remove auto-start registration. **Idempotent** — succeeds when nothing was
511/// registered, so an uninstaller can call it unconditionally without first
512/// asking whether the feature was ever turned on.
513///
514/// This is the WS4 "complete uninstall path": it removes the actual
515/// Scheduled Task / LaunchAgent / desktop entry, not merely some internal
516/// flag. `is_enabled()` afterwards is the independent check that it worked.
517pub fn disable() -> Result<(), String> {
518    #[cfg(target_os = "windows")]
519    {
520        if !is_enabled() {
521            return Ok(());
522        }
523        match schtasks_command()
524            .args(["/Delete", "/TN", AUTOSTART_ID, "/F"])
525            .stdout(std::process::Stdio::null())
526            .stderr(std::process::Stdio::null())
527            .status()
528        {
529            Ok(s) if s.success() => Ok(()),
530            Ok(s) => Err(format!("schtasks /Delete failed: {}", s)),
531            Err(e) => Err(format!("run schtasks: {}", e)),
532        }
533    }
534    #[cfg(not(target_os = "windows"))]
535    {
536        let Some(path) = artifact_path() else {
537            return Ok(());
538        };
539        match std::fs::remove_file(&path) {
540            Ok(()) => Ok(()),
541            // Already gone is success, not an error — that is what makes this
542            // safe to call unconditionally from an uninstaller.
543            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
544            Err(e) => Err(format!("remove {}: {}", path.display(), e)),
545        }
546    }
547}
548
549/// Handle the auto-start CLI verbs, if present. Returns `true` when one was
550/// handled and the process should exit.
551///
552/// Without this the module would be unreachable in a shipped build — nothing
553/// could opt in, query, or remove the registration (Codex P1 on PR #2999).
554/// A CLI surface rather than only a UI toggle is deliberate: it gives an
555/// **uninstaller** something to call, which is precisely what Workstream 4's
556/// "one complete, independently-testable uninstall path" needs. A GUI toggle
557/// can be layered on later and should call the same functions.
558///
559/// Verbs (checked before any heavy startup work, so they are cheap and cannot
560/// disturb a running instance):
561/// - `--enable-autostart`  — register, using this executable's own path
562/// - `--disable-autostart` — remove; idempotent, safe to run unconditionally
563/// - `--autostart-status`  — print `enabled` / `disabled`, exit 0 either way
564/// The channel this launcher belongs to, which owns any entry it writes
565/// (`SPEC_START_WITH_OS_2026_09_25.md` §3.5). Resolved the same way as the
566/// data dir; `"unknown"` only if that resolution fails.
567pub fn current_channel() -> String {
568    std::env::current_exe()
569        .ok()
570        .and_then(|exe| {
571            let dir = exe.parent()?.to_path_buf();
572            crate::data_dir::resolve_paths(&dir, env!("CARGO_PKG_VERSION")).ok()
573        })
574        .map(|p| p.common.channel)
575        .unwrap_or_else(|| "unknown".to_string())
576}
577
578/// Env var the Linux AppRun and wrappers set to their own path right before
579/// they exec the launcher.
580const STABLE_EXE_ENV: &str = "AGENTMUX_STABLE_EXE";
581
582static STABLE_EXE: std::sync::OnceLock<Option<std::ffi::OsString>> = std::sync::OnceLock::new();
583
584/// Read `AGENTMUX_STABLE_EXE` and remove it from this process's env, so no
585/// child (host, srv, agent shells, a dev build started from a pane) inherits
586/// a path that belongs to this launch. `APPIMAGE` is not used for the same
587/// reason: the AppImage runtime's copy leaks into every agent shell.
588///
589/// MUST be called from `main` before any thread exists (`remove_var`).
590pub fn take_stable_exe_env() {
591    let value = std::env::var_os(STABLE_EXE_ENV).filter(|v| !v.is_empty());
592    std::env::remove_var(STABLE_EXE_ENV);
593    let _ = STABLE_EXE.set(value);
594}
595
596/// What the login entry should run: something that survives updates
597/// (`SPEC_START_WITH_OS_2026_09_25.md` §3.1), not this process's own path.
598///
599/// 1. `AGENTMUX_STABLE_EXE` as captured by `take_stable_exe_env`: the
600///    AppImage file (set by AppRun), or the deb/rpm/tarball wrapper that sets
601///    the `LD_LIBRARY_PATH` the raw launcher cannot run without.
602/// 2. `current_exe()`: Windows and macOS installs, portable and dev builds.
603pub fn stable_target() -> Result<std::path::PathBuf, String> {
604    stable_target_from(
605        STABLE_EXE.get().cloned().flatten(),
606        std::env::current_exe().map_err(|e| e.to_string()),
607    )
608}
609
610fn stable_target_from(
611    stable: Option<std::ffi::OsString>,
612    current: Result<std::path::PathBuf, String>,
613) -> Result<std::path::PathBuf, String> {
614    match stable.map(std::path::PathBuf::from) {
615        Some(p) if p.is_file() => Ok(p),
616        _ => current,
617    }
618}
619
620/// The login entry as it stands on this machine.
621#[derive(Debug, Clone, PartialEq, Eq)]
622pub struct Entry {
623    /// What it runs, if readable.
624    pub target: Option<String>,
625    /// Which channel wrote it. `None` for an entry from before ownership was
626    /// recorded.
627    pub channel: Option<String>,
628}
629
630/// Read the current entry back from the platform. `None` when there is none.
631pub fn read_entry() -> Option<Entry> {
632    #[cfg(target_os = "windows")]
633    {
634        let out = schtasks_command()
635            .args(["/Query", "/TN", AUTOSTART_ID, "/XML"])
636            .stderr(std::process::Stdio::null())
637            .output()
638            .ok()?;
639        if !out.status.success() {
640            return None;
641        }
642        Some(parse_task_xml(&decode_console_output(&out.stdout)))
643    }
644    #[cfg(not(target_os = "windows"))]
645    {
646        let text = std::fs::read_to_string(artifact_path()?).ok()?;
647        Some(if cfg!(target_os = "macos") {
648            parse_plist(&text)
649        } else {
650            parse_desktop_entry(&text)
651        })
652    }
653}
654
655/// `schtasks /XML` writes UTF-16LE when its output is redirected.
656#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
657fn decode_console_output(bytes: &[u8]) -> String {
658    let body = bytes.strip_prefix(&[0xFF, 0xFE]).unwrap_or(bytes);
659    if body.len() >= 2 && body.len() % 2 == 0 && body.iter().skip(1).step_by(2).take(64).all(|b| *b == 0) {
660        let units: Vec<u16> = body.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
661        return String::from_utf16_lossy(&units);
662    }
663    String::from_utf8_lossy(body).into_owned()
664}
665
666fn between<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> {
667    let from = s.find(start)? + start.len();
668    let len = s[from..].find(end)?;
669    Some(&s[from..from + len])
670}
671
672fn xml_unescape(s: &str) -> String {
673    s.replace("&lt;", "<")
674        .replace("&gt;", ">")
675        .replace("&quot;", "\"")
676        .replace("&apos;", "'")
677        .replace("&amp;", "&")
678}
679
680#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
681fn parse_task_xml(xml: &str) -> Entry {
682    let target = between(xml, "<Command>", "</Command>")
683        .map(|c| xml_unescape(c.trim()).trim_matches('"').to_string());
684    let channel = between(xml, "<Description>", "</Description>")
685        .and_then(|d| between(d, TASK_CHANNEL_MARKER, "]"))
686        .map(xml_unescape);
687    Entry { target, channel }
688}
689
690#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
691fn parse_plist(text: &str) -> Entry {
692    let target = between(text, "<key>ProgramArguments</key>", "</array>")
693        .and_then(|a| between(a, "<string>", "</string>"))
694        .map(xml_unescape);
695    let channel = between(text, &format!("<key>{PLIST_CHANNEL_KEY}</key>"), "</string>")
696        .and_then(|rest| rest.split_once("<string>").map(|(_, v)| xml_unescape(v)));
697    Entry { target, channel }
698}
699
700#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
701fn parse_desktop_entry(text: &str) -> Entry {
702    let value = |key: &str| {
703        text.lines()
704            .find_map(|l| l.strip_prefix(key).and_then(|r| r.strip_prefix('=')))
705            .map(desktop_string_unescape)
706    };
707    // `TryExec` is the plain path; an entry from before it existed has none,
708    // reads as "target unknown", and gets rewritten.
709    Entry { target: value("TryExec"), channel: value(XDG_CHANNEL_KEY) }
710}
711
712/// What to do to bring the login entry in line with `app:startatlogin`
713/// (`SPEC_START_WITH_OS_2026_09_25.md` §3.9).
714#[derive(Debug, Clone, PartialEq, Eq)]
715pub enum Plan {
716    /// Already right, or not ours to touch.
717    Nothing,
718    /// Write (or rewrite) the entry for this channel and target.
719    Write,
720    /// Remove this channel's entry.
721    Remove,
722    /// An entry from before the setting existed, and the setting was never
723    /// set: the user turned start-at-login on with the old toggle, so record
724    /// that as `app:startatlogin = true` instead of removing it.
725    Adopt,
726}
727
728/// Pure decision. `setting` is `app:startatlogin`, `None` when never set.
729/// `target` is `stable_target()`, `None` when it could not be resolved: only
730/// writing needs it, so removing or adopting never waits on it (ReAgent P1 on
731/// #3788).
732///
733/// - On: write unless the entry already runs `target` for this channel. This
734///   also re-points an entry after an update or a move, and takes ownership
735///   from another channel (last enabled wins).
736/// - Off: remove only an entry this channel owns. Another build's entry, or
737///   one from before ownership was recorded, is not this build's to remove.
738/// - Never set: off, except that an unowned (pre-setting) entry is adopted.
739pub fn plan(setting: Option<bool>, entry: Option<&Entry>, channel: &str, target: Option<&str>) -> Plan {
740    let owned = |e: &Entry| e.channel.as_deref() == Some(channel);
741    match (setting, entry) {
742        (Some(true), Some(e)) if owned(e) && target.is_some() && e.target.as_deref() == target => Plan::Nothing,
743        (Some(true), _) => Plan::Write,
744        (Some(false), Some(e)) if owned(e) => Plan::Remove,
745        (None, Some(e)) if e.channel.is_none() => Plan::Adopt,
746        _ => Plan::Nothing,
747    }
748}
749
750pub fn handle_cli(args: &[String]) -> bool {
751    let has = |v: &str| args.iter().any(|a| a == v);
752
753    if has("--autostart-status") {
754        println!("{}", if is_enabled() { "enabled" } else { "disabled" });
755        return true;
756    }
757    if has("--enable-autostart") {
758        match stable_target() {
759            Ok(exe) => match enable(&exe, &current_channel()) {
760                Ok(()) => println!("auto-start enabled ({})", exe.display()),
761                Err(e) => {
762                    eprintln!("failed to enable auto-start: {}", e);
763                    std::process::exit(1);
764                }
765            },
766            Err(e) => {
767                eprintln!("failed to resolve the auto-start target: {}", e);
768                std::process::exit(1);
769            }
770        }
771        return true;
772    }
773    if has("--disable-autostart") {
774        match disable() {
775            Ok(()) => println!("auto-start disabled"),
776            Err(e) => {
777                eprintln!("failed to disable auto-start: {}", e);
778                std::process::exit(1);
779            }
780        }
781        return true;
782    }
783    false
784}
785
786#[cfg(test)]
787mod autostart_tests {
788    use super::*;
789    use std::path::PathBuf;
790
791    fn exe() -> PathBuf {
792        PathBuf::from(r"C:\Program Files\AgentMux\agentmux.exe")
793    }
794
795    #[test]
796    fn scheduled_task_uses_interactive_token_not_a_service_account() {
797        // The whole reason WS2 specifies a Scheduled Task over a Windows
798        // Service: Session-0 isolation means a service can never own a tray
799        // icon or show a window. InteractiveToken is what puts it in the
800        // user's own session.
801        let xml = scheduled_task_xml(&exe(), "test");
802        assert!(xml.contains("<LogonType>InteractiveToken</LogonType>"));
803        assert!(
804            xml.contains("<RunLevel>LeastPrivilege</RunLevel>"),
805            "auto-start must never require elevation"
806        );
807    }
808
809    #[test]
810    fn scheduled_task_survives_battery_power() {
811        // Task Scheduler defaults BOTH of these to true, which would make a
812        // laptop user's "background service" silently fail to start on
813        // battery, or die mid-turn on unplug — the unreliable-indicator
814        // failure WS4 exists to prevent.
815        let xml = scheduled_task_xml(&exe(), "test");
816        assert!(xml.contains("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"));
817        assert!(xml.contains("<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>"));
818    }
819
820    #[test]
821    fn scheduled_task_has_no_execution_time_limit() {
822        // PT0S = unlimited. The default is 72 hours, after which Task
823        // Scheduler would terminate a long-running background service.
824        let xml = scheduled_task_xml(&exe(), "test");
825        assert!(xml.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
826    }
827
828    #[test]
829    fn generated_xml_escapes_paths() {
830        // Install paths can contain `&` (and on Unix, quotes/angle brackets).
831        // Unescaped, that produces XML that Task Scheduler rejects — or worse,
832        // silently misparses.
833        let xml = scheduled_task_xml(Path::new(r"C:\Tools & Apps\<agentmux>.exe"), "test");
834        assert!(xml.contains("Tools &amp; Apps"));
835        assert!(xml.contains("&lt;agentmux&gt;"));
836        assert!(
837            !xml.contains("Tools & Apps"),
838            "raw ampersand would make the document invalid"
839        );
840    }
841
842    #[test]
843    fn launch_agent_does_not_let_launchd_resurrect_a_user_quit() {
844        // KeepAlive=false is deliberate: the launcher owns its own restart
845        // policy, and a tray Quit that launchd immediately undid would be
846        // worse than having no Quit at all.
847        let plist = launch_agent_plist(Path::new("/Applications/AgentMux.app/Contents/MacOS/agentmux"), "test");
848        assert!(plist.contains("<key>RunAtLoad</key>\n    <true/>"));
849        assert!(plist.contains("<key>KeepAlive</key>\n    <false/>"));
850        assert!(plist.contains(MACOS_LABEL));
851    }
852
853    #[test]
854    fn xdg_entry_is_a_valid_autostart_desktop_file() {
855        let d = xdg_desktop_entry(Path::new("/opt/agentmux/agentmux"), "test");
856        assert!(d.starts_with("[Desktop Entry]\n"));
857        assert!(d.contains("Type=Application\n"));
858        assert!(d.contains("X-GNOME-Autostart-enabled=true\n"));
859        assert!(
860            d.contains("Exec=\"/opt/agentmux/agentmux\" --background\n"),
861            "Exec must quote the path so spaces don't split the command"
862        );
863    }
864
865    #[test]
866    fn xdg_entry_cannot_be_injected_with_extra_keys_via_the_path() {
867        // A newline in the path would otherwise terminate the Exec= line and
868        // let the rest be parsed as further desktop-entry keys.
869        let d = xdg_desktop_entry(Path::new("/opt/x\nHidden=true"), "test");
870        assert!(!d.contains("\nHidden=true\n"), "newline must not survive into the file");
871    }
872
873    /// Full enable → is_enabled → disable → is_enabled round trip against the
874    /// REAL platform registry (Task Scheduler / LaunchAgents / XDG autostart).
875    ///
876    /// `#[ignore]` on purpose: it mutates the machine's actual login
877    /// configuration, which is not something a plain `cargo test` — least of
878    /// all one on a shared CI runner — should ever do as a side effect. Run it
879    /// deliberately:
880    ///
881    /// ```text
882    /// cargo test -p agentmux-launcher --release -- --ignored autostart_round_trip
883    /// ```
884    ///
885    /// This is the WS4 "independently-testable uninstall path" made literal:
886    /// it verifies removal by asking the platform, not by trusting our own
887    /// bookkeeping. Self-cleaning — it disables at the end, and asserts the
888    /// artifact is actually gone.
889    #[test]
890    #[ignore = "mutates real login configuration; run explicitly with --ignored"]
891    fn autostart_round_trip_against_the_real_platform_registry() {
892        // Refuse to run if the user already has autostart configured, rather
893        // than clobbering (and then deleting) their real registration.
894        assert!(
895            !is_enabled(),
896            "autostart already enabled on this machine — refusing to overwrite a real registration"
897        );
898
899        let exe = std::env::current_exe().expect("current_exe");
900        enable(&exe, "test").expect("enable should succeed");
901        assert!(is_enabled(), "enable() must be observable through the platform");
902
903        // Idempotent re-enable (the upgrade path: repoint at a new exe).
904        enable(&exe, "test").expect("re-enable should replace, not fail");
905        assert!(is_enabled());
906
907        disable().expect("disable should succeed");
908        assert!(!is_enabled(), "disable() must actually remove the artifact");
909
910        // Safe to call unconditionally — the property an uninstaller needs.
911        disable().expect("disable must be idempotent");
912    }
913
914    /// Codex P1 on PR #2999: `--background` was forwarded but interpreted
915    /// nowhere, so an auto-started instance would open an ordinary foreground
916    /// window and still quit on last-window-close. The flag must map onto the
917    /// env the host actually reads.
918    #[test]
919    fn the_background_flag_maps_onto_the_env_the_host_actually_reads() {
920        let args = vec!["agentmux.exe".to_string(), BACKGROUND_FLAG.to_string()];
921        assert!(background_requested(&args));
922        let env = background_env_for(&args);
923        assert!(
924            env.contains(&("AGENTMUX_BACKGROUND_SERVICE", "1")),
925            "without this the auto-start entry is inert"
926        );
927    }
928
929    /// Auto-start is the one context where the indicator is not optional:
930    /// a process that starts itself at login and runs with no window and no
931    /// icon is exactly the invisible-background-agent pattern WS4 exists to
932    /// prevent. So `--background` implies the tray.
933    #[test]
934    fn background_mode_also_forces_the_visible_indicator_on() {
935        let args = vec![BACKGROUND_FLAG.to_string()];
936        assert!(background_env_for(&args).contains(&("AGENTMUX_TRAY", "1")));
937    }
938
939    #[test]
940    fn a_normal_launch_sets_no_background_env() {
941        let args = vec!["agentmux.exe".to_string(), "--url=http://x".to_string()];
942        assert!(!background_requested(&args));
943        assert!(
944            background_env_for(&args).is_empty(),
945            "an ordinary launch must be completely unaffected"
946        );
947    }
948
949    #[test]
950    fn exec_escaping_covers_the_desktop_entry_grammar() {
951        // Every one of these is legal in a Unix path and each changes how the
952        // Exec value tokenizes if left raw.
953        let d = xdg_desktop_entry(Path::new(r#"/opt/a"b\c`d$e%f/agentmux"#), "test");
954        assert!(d.contains(r#"\"b"#), "quote must be escaped, not end the argument");
955        assert!(d.contains(r"\\c"), "backslash must be escaped");
956        assert!(d.contains(r"\`d"), "backtick must be escaped");
957        assert!(d.contains(r"\$e"), "dollar must be escaped");
958        assert!(d.contains("%%f"), "percent must be doubled or it reads as a field code");
959    }
960
961    #[test]
962    fn every_platform_artifact_launches_with_the_background_flag() {
963        // All three must start the app in background mode; a plain launch
964        // would pop a window in the user's face at every login, which is the
965        // opposite of what auto-start is for.
966        assert!(scheduled_task_xml(&exe(), "test").contains("--background"));
967        assert!(launch_agent_plist(&exe(), "test").contains("<string>--background</string>"));
968        assert!(xdg_desktop_entry(&exe(), "test").contains("--background"));
969    }
970
971    // ── SPEC_START_WITH_OS_2026_09_25 ─────────────────────────────────────
972
973    #[test]
974    fn a_login_start_that_finds_agentmux_running_opens_no_window() {
975        let manual = vec!["agentmux".to_string()];
976        let login = vec!["agentmux".to_string(), BACKGROUND_FLAG.to_string()];
977        assert!(second_instance_opens_window(&manual));
978        assert!(!second_instance_opens_window(&login));
979    }
980
981    fn entry(target: &str, channel: Option<&str>) -> Entry {
982        Entry { target: Some(target.to_string()), channel: channel.map(str::to_string) }
983    }
984
985    #[test]
986    fn plan_on_writes_unless_the_entry_is_already_ours_and_current() {
987        let t = "/home/u/Desktop/AgentMux.AppImage";
988        assert_eq!(plan(Some(true), None, "stable", Some(t)), Plan::Write);
989        assert_eq!(plan(Some(true), Some(&entry(t, Some("stable"))), "stable", Some(t)), Plan::Nothing);
990        // An update or a move: same owner, old path.
991        assert_eq!(plan(Some(true), Some(&entry("/old/path", Some("stable"))), "stable", Some(t)), Plan::Write);
992        // Another build's entry: turning it on here takes it over.
993        assert_eq!(plan(Some(true), Some(&entry(t, Some("local-main-x"))), "stable", Some(t)), Plan::Write);
994    }
995
996    #[test]
997    fn plan_off_removes_only_this_channels_entry() {
998        let t = "/opt/agentmux/agentmux";
999        assert_eq!(plan(Some(false), Some(&entry(t, Some("stable"))), "stable", Some(t)), Plan::Remove);
1000        assert_eq!(plan(Some(false), Some(&entry(t, Some("local-main-x"))), "stable", Some(t)), Plan::Nothing);
1001        assert_eq!(plan(Some(false), Some(&entry(t, None)), "stable", Some(t)), Plan::Nothing);
1002        assert_eq!(plan(Some(false), None, "stable", Some(t)), Plan::Nothing);
1003    }
1004
1005    /// ReAgent P1 on #3788: an unresolvable target must not block turning it off.
1006    #[test]
1007    fn plan_needs_the_target_only_to_write() {
1008        let mine = entry("/x", Some("stable"));
1009        assert_eq!(plan(Some(false), Some(&mine), "stable", None), Plan::Remove);
1010        assert_eq!(plan(None, Some(&entry("/x", None)), "stable", None), Plan::Adopt);
1011        // On with no target: still a write, which then reports the missing target.
1012        assert_eq!(plan(Some(true), Some(&mine), "stable", None), Plan::Write);
1013    }
1014
1015    /// Off by default, but a user who turned it on with the old toggle keeps it.
1016    #[test]
1017    fn plan_never_set_is_off_but_adopts_an_entry_from_before_the_setting() {
1018        let t = "/x";
1019        assert_eq!(plan(None, None, "stable", Some(t)), Plan::Nothing);
1020        assert_eq!(plan(None, Some(&entry(t, None)), "stable", Some(t)), Plan::Adopt);
1021        assert_eq!(plan(None, Some(&entry(t, Some("local-main-x"))), "stable", Some(t)), Plan::Nothing);
1022    }
1023
1024    #[test]
1025    fn every_artifact_reads_back_its_own_target_and_channel() {
1026        let exe = Path::new("/home/u/My Apps/Agent&Mux.AppImage");
1027        let want = Entry { target: Some(exe.display().to_string()), channel: Some("local-main-b28b7a".into()) };
1028        assert_eq!(parse_desktop_entry(&xdg_desktop_entry(exe, "local-main-b28b7a")), want);
1029        assert_eq!(parse_plist(&launch_agent_plist(exe, "local-main-b28b7a")), want);
1030        let win = Path::new(r"C:\Users\u\AppData\Local\Programs\AgentMux & Co\agentmux.exe");
1031        assert_eq!(
1032            parse_task_xml(&scheduled_task_xml(win, "stable")),
1033            Entry { target: Some(win.display().to_string()), channel: Some("stable".into()) }
1034        );
1035    }
1036
1037    /// An entry written before ownership existed has no channel and no TryExec.
1038    #[test]
1039    fn a_pre_ownership_desktop_entry_reads_as_unowned_with_no_target() {
1040        let old = "[Desktop Entry]\nType=Application\nExec=\"/x/agentmux-launcher\" --background\n";
1041        assert_eq!(parse_desktop_entry(old), Entry { target: None, channel: None });
1042    }
1043
1044    #[test]
1045    fn the_desktop_entry_goes_inert_without_its_target_and_waits_for_the_session() {
1046        let d = xdg_desktop_entry(Path::new("/opt/agentmux/agentmux"), "stable");
1047        assert!(d.contains("\nTryExec=/opt/agentmux/agentmux\n"), "{d}");
1048        assert!(d.contains("\nX-GNOME-Autostart-Delay=10\n"), "{d}");
1049        assert!(d.contains("\nX-AgentMux-Channel=stable\n"), "{d}");
1050    }
1051
1052    #[test]
1053    fn try_exec_cannot_be_used_to_inject_keys() {
1054        let d = xdg_desktop_entry(Path::new("/opt/x\nHidden=true"), "stable");
1055        assert!(!d.lines().any(|l| l == "Hidden=true"), "{d}");
1056    }
1057
1058    #[test]
1059    fn schtasks_utf16_output_is_decoded() {
1060        let mut bytes = vec![0xFF, 0xFE];
1061        for u in "<Command>\"C:\\a.exe\"</Command>".encode_utf16() {
1062            bytes.extend_from_slice(&u.to_le_bytes());
1063        }
1064        assert_eq!(decode_console_output(&bytes), "<Command>\"C:\\a.exe\"</Command>");
1065        assert_eq!(decode_console_output(b"plain"), "plain");
1066    }
1067
1068    #[test]
1069    fn the_stable_target_prefers_the_wrapper_path_then_this_exe() {
1070        let dir = std::env::temp_dir().join(format!("agentmux-stable-{}", std::process::id()));
1071        std::fs::create_dir_all(&dir).unwrap();
1072        let wrapper = dir.join("agentmux");
1073        std::fs::write(&wrapper, "").unwrap();
1074        let cur = || Ok(PathBuf::from("/cache/agentmux-launcher"));
1075
1076        assert_eq!(stable_target_from(Some(wrapper.clone().into()), cur()), Ok(wrapper.clone()));
1077        // A value pointing nowhere is skipped, not written.
1078        assert_eq!(stable_target_from(Some(dir.join("gone").into()), cur()), cur());
1079        assert_eq!(stable_target_from(None, cur()), cur());
1080        let _ = std::fs::remove_dir_all(&dir);
1081    }
1082}