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}