agentmux_cef/
parent_process.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parent-process identity check used by the launcher-IPC connection
5//! guard in `main.rs`. Returns true when the host's parent process is
6//! the AgentMux launcher.
7//!
8//! Background — see `docs/specs/SPEC_DEV_MODE_LAUNCHER_IPC_2026_05_16.md`.
9//! Before this helper, the connect-to-launcher gate used
10//! `is_dev_build_exe(exe_dir)` as a proxy for "the launcher is not
11//! running, skip IPC". That worked when `task dev` invoked the host
12//! directly. After `SPEC_LAUNCHER_DEV_INTEGRATION_2026-05-13.md` made
13//! `task dev` spawn the host via the launcher (production-parallel
14//! layout), the path-based guard wrongly skipped legitimate IPC in dev,
15//! breaking `WindowOpened` / `BackendWindowIdRegistered` event delivery
16//! (visible as: status-bar window-count desync and missing opacity
17//! slider in dev).
18//!
19//! Parent-process check is a tighter discriminator: it admits the dev
20//! launcher (correct) and still rejects a standalone dev host that
21//! happened to inherit `AGENTMUX_LAUNCHER_PIPE` from a parent shell
22//! (also correct — that's the original isolation concern).
23
24/// Exe filenames we accept as "the AgentMux launcher." Compared
25/// case-insensitively after stripping the `.exe` extension.
26///
27/// - `agentmux-launcher` — the Cargo bin name. Used directly in dev
28///   (`task dev` copies `target/release/agentmux-launcher.exe` into
29///   `dist/cef-dev/`).
30/// - `agentmux` — the user-facing name in portable / installed builds.
31///   `scripts/package-portable.sh` copies the launcher to
32///   `agentmux.exe` so the icon the user double-clicks reads as
33///   "AgentMux", not "AgentMux Launcher." `PROCESSENTRY32W.szExeFile`
34///   returns the on-disk file name, so the parent stem from a
35///   production launch is `agentmux`, not `agentmux-launcher` —
36///   codex P1 on PR #882 round 1 caught this would regress every
37///   portable build.
38const ACCEPTED_PARENT_STEMS: &[&str] = &["agentmux-launcher", "agentmux"];
39
40/// Returns `Some(true)` if the host's parent process is the AgentMux
41/// launcher (under any of its on-disk names), `Some(false)` if it's
42/// something else, or `None` if the parent identity couldn't be
43/// determined (snapshot creation failed, parent process exited
44/// between PID discovery and lookup). Callers treat `None` as "fall
45/// through to the path-based guard" — see the call site.
46#[cfg(target_os = "windows")]
47pub fn parent_is_agentmux_launcher() -> Option<bool> {
48    let parent_exe = parent_exe_file_windows()?;
49    // Lower-case once, then strip the lowercase suffix — handles any
50    // capitalization of the extension (`.exe`, `.EXE`, `.Exe`, etc.)
51    // in a single branch. Reagent P2 on round 3 noted the previous
52    // two-arm `or_else` chain missed mixed-case variants.
53    let parent_exe_lower = parent_exe.to_ascii_lowercase();
54    let stem = parent_exe_lower
55        .strip_suffix(".exe")
56        .unwrap_or(&parent_exe_lower);
57    // `stem` is already lowercased above and `ACCEPTED_PARENT_STEMS`
58    // entries are lowercase literals — plain `==` is sufficient.
59    Some(ACCEPTED_PARENT_STEMS.iter().any(|accepted| stem == *accepted))
60}
61
62#[cfg(not(target_os = "windows"))]
63pub fn parent_is_agentmux_launcher() -> Option<bool> {
64    // Linux/macOS launcher integration is on a separate roadmap
65    // (Phase 7 cross-platform parity per
66    // SPEC_LAUNCHER_DEV_INTEGRATION_2026-05-13.md). On those
67    // platforms the host is invoked directly by `task dev` and IPC
68    // is not in play, so the parent-check is moot — return None and
69    // let the path-based guard decide.
70    None
71}
72
73/// Walk the Toolhelp32 process snapshot in a single pass to:
74///   1. Find the current PID's entry → record `th32ParentProcessID`.
75///   2. Find the entry where `th32ProcessID == parent_pid` → capture
76///      its `szExeFile`.
77///
78/// Uses `PROCESSENTRY32W.szExeFile` (a fixed 260-wide-char buffer of
79/// the executable's *filename only*, no path) rather than
80/// `QueryFullProcessImageNameW`. Per codex P2 on PR #882 round 2,
81/// the latter could fail when a Windows checkout's staged launcher
82/// path exceeds MAX_PATH — `szExeFile` is filename-only and never
83/// hits that limit.
84#[cfg(target_os = "windows")]
85fn parent_exe_file_windows() -> Option<String> {
86    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
87    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
88        CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
89        TH32CS_SNAPPROCESS,
90    };
91    use windows_sys::Win32::System::Threading::GetCurrentProcessId;
92
93    // SAFETY: Toolhelp32 snapshot APIs are documented to be safe to
94    // call from any thread; we close the returned handle on every
95    // exit path. PROCESSENTRY32W is initialized with its size field
96    // before the first call as the Win32 API requires.
97    unsafe {
98        let me = GetCurrentProcessId();
99        let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
100        if snap == INVALID_HANDLE_VALUE {
101            return None;
102        }
103        let mut entry: PROCESSENTRY32W = std::mem::zeroed();
104        entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
105
106        // First pass: find current PID's parent PID.
107        let mut parent_pid: Option<u32> = None;
108        let mut ok = Process32FirstW(snap, &mut entry);
109        while ok != 0 {
110            if entry.th32ProcessID == me {
111                parent_pid = Some(entry.th32ParentProcessID);
112                break;
113            }
114            ok = Process32NextW(snap, &mut entry);
115        }
116
117        let parent_pid = match parent_pid {
118            Some(p) => p,
119            None => {
120                CloseHandle(snap);
121                return None;
122            }
123        };
124
125        // Second pass: re-snapshot to walk from start. CreateToolhelp32Snapshot's
126        // cursor isn't documented as rewindable, so the safest approach is a
127        // fresh snapshot rather than relying on iterator state after `break`.
128        CloseHandle(snap);
129        let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
130        if snap == INVALID_HANDLE_VALUE {
131            return None;
132        }
133        let mut entry: PROCESSENTRY32W = std::mem::zeroed();
134        entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
135
136        let mut parent_exe: Option<String> = None;
137        let mut ok = Process32FirstW(snap, &mut entry);
138        while ok != 0 {
139            if entry.th32ProcessID == parent_pid {
140                let len = entry
141                    .szExeFile
142                    .iter()
143                    .position(|&c| c == 0)
144                    .unwrap_or(entry.szExeFile.len());
145                parent_exe = Some(String::from_utf16_lossy(&entry.szExeFile[..len]));
146                break;
147            }
148            ok = Process32NextW(snap, &mut entry);
149        }
150        CloseHandle(snap);
151        parent_exe
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    /// Smoke test: on Windows the helper should return Some(_) for the
160    /// test runner's parent (cargo / vstest). On other platforms it
161    /// returns None by construction.
162    #[test]
163    fn parent_check_resolves() {
164        let result = parent_is_agentmux_launcher();
165        #[cfg(target_os = "windows")]
166        {
167            // Some platforms / CI runners may fail the snapshot under
168            // restricted permissions; we accept None there. What we DO
169            // assert: when it returns Some, it must be false (cargo /
170            // vstest are never named "agentmux-launcher" or "agentmux").
171            if let Some(b) = result {
172                assert!(!b, "parent should not be the AgentMux launcher under test");
173            }
174        }
175        #[cfg(not(target_os = "windows"))]
176        {
177            assert!(result.is_none(), "non-windows always returns None");
178        }
179    }
180}