Skip to main content

agentmux_cef/
lib.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// AgentMux Host — Library entry point.
5//
6// This cdylib is the Windows DLL wrapper sandbox target (Phase 3, issue #1374).
7// `bootstrap.exe` (renamed `agentmux-cef.exe`) loads this DLL and calls
8// `RunWinMain` with a pre-initialized `cef_sandbox_info_t *` pointer, which we
9// forward into `run()`.
10//
11// On non-Windows or sandbox-off builds, only the `[[bin]]` target is used;
12// this file must still compile on those platforms.
13
14mod app;
15mod background_audit;
16mod start_hidden;
17mod browser_api;
18mod browser_panes;
19mod cdp_port;
20mod client;
21mod commands;
22mod dev_authfile;
23mod drag_stash;
24mod events;
25mod health_signals;
26mod ipc;
27mod launcher_event_bridge;
28mod launcher_ipc;
29// Not #[cfg]-gated at the mod-declaration level like macos_compat/macos_menu
30// below — this module deliberately mixes OS-agnostic pure logic (unit
31// tested on any host) with target_os = "linux"-gated OS interaction
32// (internally gated within the file itself). See its own doc comment.
33mod linux_sandbox;
34mod logging;
35mod parent_process;
36mod srv_event_bridge;
37mod srv_ipc;
38mod memory_heartbeat;
39mod memory_pressure;
40mod browser_pane;
41mod credential_broker;
42mod memory_adoption;
43mod ssh_approval;
44#[cfg(target_os = "windows")]
45mod floating_pane;
46mod reducer;
47mod renderer_map;
48mod saga_dispatch;
49mod sidecar;
50mod state;
51mod ui_tasks;
52mod wrr;
53#[cfg(target_os = "macos")]
54mod macos_compat;
55#[cfg(target_os = "macos")]
56mod macos_menu;
57
58use std::sync::Arc;
59
60use cef::*;
61
62/// Suppress the Windows "Application Error" / WER crash dialog so an unhandled
63/// fault (a Chromium `LOG(FATAL)`, an `abort()`, a breakpoint) terminates the
64/// process immediately instead of wedging it behind a modal the user must
65/// dismiss. While that dialog is up the process is frozen and cannot be
66/// auto-recovered. No-op off Windows. Spec:
67/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
68#[cfg(target_os = "windows")]
69fn suppress_os_crash_dialogs() {
70    use windows_sys::Win32::System::Diagnostics::Debug::{SetErrorMode, SEM_FAILCRITICALERRORS};
71    use windows_sys::Win32::System::ErrorReporting::{WerSetFlags, WER_FAULT_REPORTING_NO_UI};
72    // Process-wide; also covers the CEF subprocesses.
73    unsafe {
74        // Suppress the WER crash-dialog UI WITHOUT disabling WER itself —
75        // SEM_NOGPFAULTERRORBOX would also kill WER/LocalDumps crash-dump
76        // collection, the postmortem diagnostics this stability work needs.
77        // WER_FAULT_REPORTING_NO_UI is the documented "no UI, keep
78        // reports" path.
79        let _ = WerSetFlags(WER_FAULT_REPORTING_NO_UI);
80        // SEM_FAILCRITICALERRORS suppresses the critical-error handler
81        // (e.g. "no disk in drive" popups) — unrelated to crash reporting.
82        SetErrorMode(SEM_FAILCRITICALERRORS);
83    }
84}
85
86#[cfg(not(target_os = "windows"))]
87fn suppress_os_crash_dialogs() {}
88
89/// Resolve CEF's `browser_subprocess_path` (the executable CEF spawns for
90/// renderer / GPU / utility processes).
91///
92/// On a packaged macOS `.app`, return the dedicated `AgentMux Helper`
93/// executable inside `Contents/Frameworks/AgentMux Helper.app` — re-execing
94/// the main bundle binary for subprocesses is rejected by the macOS process
95/// model (every child would inherit the main bundle's identity), which makes
96/// the renderers crash-loop. When no Helper.app is present (dev: a bare binary
97/// with no bundle) fall back to the current exe — self-reexec works there.
98/// Windows/Linux always use the current exe (self-reexec is fine off-bundle).
99#[cfg(target_os = "macos")]
100fn resolve_browser_subprocess_path() -> String {
101    let exe = std::env::current_exe().unwrap();
102    // exe = AgentMux.app/Contents/MacOS/agentmux-cef
103    // → AgentMux.app/Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
104    if let Some(contents) = exe.parent().and_then(|macos| macos.parent()) {
105        let helper = contents
106            .join("Frameworks")
107            .join("AgentMux Helper.app")
108            .join("Contents")
109            .join("MacOS")
110            .join("AgentMux Helper");
111        if helper.exists() {
112            return helper.to_string_lossy().into_owned();
113        }
114    }
115    exe.to_string_lossy().into_owned()
116}
117
118#[cfg(not(target_os = "macos"))]
119fn resolve_browser_subprocess_path() -> String {
120    std::env::current_exe()
121        .ok()
122        .and_then(|p| p.to_str().map(|s| s.to_owned()))
123        .unwrap_or_default()
124}
125
126/// True when a launcher is THIS host's actual parent process this run.
127///
128/// The launcher (which spawns the host directly) stamps
129/// `AGENTMUX_LAUNCHER_PID` with its own pid; since we are its direct
130/// child, that pid equals our `getppid()`. This distinguishes a fresh,
131/// authoritative launcher hand-off from a STALE `AGENTMUX_BACKEND_WS`
132/// inherited down the environment from a parent agentmux pane (whose
133/// launcher pid will not match our real parent). Used only to relax the
134/// dev-build "ignore the env hand-off" rule for launcher-driven `task dev`
135/// (macOS/Linux Phase 1 launcher dev integration; Windows since #3868's
136/// follow-up — before that a Windows dev host always spawned a SECOND srv
137/// on the same data dir beside the launcher's).
138#[cfg(unix)]
139fn launcher_is_genuine_parent() -> bool {
140    // SAFETY: getppid() takes no arguments, touches no memory, and is
141    // documented as always succeeding.
142    let ppid = unsafe { libc::getppid() };
143    stamped_pid_is_parent(
144        std::env::var("AGENTMUX_LAUNCHER_PID").ok().as_deref(),
145        u32::try_from(ppid).ok(),
146    )
147}
148
149#[cfg(windows)]
150fn launcher_is_genuine_parent() -> bool {
151    stamped_pid_is_parent(
152        std::env::var("AGENTMUX_LAUNCHER_PID").ok().as_deref(),
153        parent_process::parent_pid(),
154    )
155}
156
157#[cfg(not(any(unix, windows)))]
158fn launcher_is_genuine_parent() -> bool {
159    false
160}
161
162/// `stamp` (the launcher's `AGENTMUX_LAUNCHER_PID`) names our actual
163/// parent. No stamp, an unparsable one, or an unknown parent → false.
164fn stamped_pid_is_parent(stamp: Option<&str>, parent_pid: Option<u32>) -> bool {
165    match (stamp.and_then(|s| s.trim().parse::<u32>().ok()), parent_pid) {
166        (Some(stamped), Some(parent)) => stamped == parent,
167        _ => false,
168    }
169}
170
171#[cfg(test)]
172mod launcher_parent_tests {
173    use super::stamped_pid_is_parent;
174
175    #[test]
176    fn matches_only_the_real_parent() {
177        assert!(stamped_pid_is_parent(Some("4242"), Some(4242)));
178        assert!(stamped_pid_is_parent(Some(" 4242\n"), Some(4242)));
179        // A stamp inherited from a parent agentmux pane names some other process.
180        assert!(!stamped_pid_is_parent(Some("4242"), Some(777)));
181    }
182
183    #[test]
184    fn missing_or_bad_input_is_not_a_launcher_parent() {
185        assert!(!stamped_pid_is_parent(None, Some(4242)));
186        assert!(!stamped_pid_is_parent(Some(""), Some(4242)));
187        assert!(!stamped_pid_is_parent(Some("-1"), Some(4242)));
188        assert!(!stamped_pid_is_parent(Some("abc"), Some(4242)));
189        assert!(!stamped_pid_is_parent(Some("4242"), None));
190    }
191}
192
193/// Run the AgentMux host.
194///
195/// `windows_sandbox_info` is the `cef_sandbox_info_t *` created by
196/// `bootstrap.exe` before loading this DLL (Windows + sandbox feature), or
197/// `null_mut()` on all other platforms / sandbox-off builds.
198pub fn run(windows_sandbox_info: *mut std::ffi::c_void) -> i32 {
199    // Internal self-probe mode (Linux userns-sandbox detection) — must be
200    // the very first check in this function, before anything else: a
201    // probe-mode process does nothing but this one syscall and exits, so
202    // none of the startup work below is relevant to it. See
203    // linux_sandbox::run_internal_userns_probe_and_exit()'s doc comment.
204    #[cfg(target_os = "linux")]
205    if std::env::args().any(|a| a == linux_sandbox::INTERNAL_PROBE_USERNS_FLAG) {
206        linux_sandbox::run_internal_userns_probe_and_exit();
207    }
208    // Same for the GL-renderer probe behind the hw-gl GPU tier: it only makes
209    // a throwaway EGL context and exits (app::gl_probe).
210    #[cfg(target_os = "linux")]
211    if std::env::args().any(|a| a == app::gl_probe::INTERNAL_PROBE_GL_FLAG) {
212        app::gl_probe::run_internal_gl_probe_and_exit();
213    }
214
215    // Phase 0 (service supervision & recovery): suppress the Windows crash
216    // modal so a fault terminates the process immediately instead of freezing
217    // it behind an "Application Error" dialog. Must be the first statement —
218    // set before anything can fault.
219    suppress_os_crash_dialogs();
220
221    // Set the DLL search path so CEF's runtime LoadLibrary calls (chrome_elf,
222    // libEGL, libGLESv2, d3dcompiler_47, …) resolve against the directory that
223    // actually holds libcef.dll. Two layouts exist:
224    //
225    //   Portable / installed: <root>/runtime/host.exe + libcef.dll alongside.
226    //                         The launcher (agentmux-launcher) already sets
227    //                         the path to <root>/runtime/ before spawning us;
228    //                         this block is a no-op safety net for that mode.
229    //
230    //   Dev (`task dev`):     dist/cef-dev/agentmux-cef.exe + libcef.dll
231    //                         alongside (flat layout). Taskfile launches the
232    //                         host directly with no launcher, so nothing else
233    //                         has set the DLL path. Without it, CEF's internal
234    //                         LoadLibrary chain can fail and `cef::initialize`
235    //                         returns 0 — the empty-chrome_debug.log mode.
236    //
237    // Fall back to the host's own directory whenever a runtime/ subdir isn't
238    // present. Idempotent in portable mode (launcher set it first), correct
239    // in dev mode.
240    #[cfg(target_os = "windows")]
241    {
242        if let Ok(exe) = std::env::current_exe() {
243            if let Some(dir) = exe.parent() {
244                let runtime_dir = dir.join("runtime");
245                let dll_dir = if runtime_dir.exists() {
246                    runtime_dir
247                } else {
248                    dir.to_path_buf()
249                };
250                unsafe {
251                    use std::os::windows::ffi::OsStrExt;
252                    let wide: Vec<u16> = dll_dir.as_os_str().encode_wide().chain(Some(0)).collect();
253                    windows_sys::Win32::System::LibraryLoader::SetDllDirectoryW(wide.as_ptr());
254                }
255            }
256        }
257    }
258
259    // Tracing is initialized after the subprocess check below — browser process
260    // gets dual file+stderr output; subprocesses exit before tracing is needed.
261
262    // Escape hatch — read once here so both macOS subprocess sandbox init
263    // (below) and the browser-process Settings construction share the same
264    // value. Only mutated on the target_os = "linux" branch below.
265    #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
266    let mut force_no_sandbox = std::env::var("AGENTMUX_UNSAFE_NOSANDBOX")
267        .map(|v| v.trim() == "1")
268        .unwrap_or(false);
269
270    // Linux-only, browser-process-only: proactively probe whether the
271    // kernel-namespace sandbox will actually work BEFORE ever attempting
272    // CefInitialize, and offer a one-time AppArmor fix (or an explicit,
273    // visible downgrade) instead of letting a doomed init attempt trail
274    // into the generic post-init-failure path further down (search this
275    // file for "CEF initialization failed"). Must be skipped for
276    // subprocess roles — CEF re-execs this same binary for every
277    // renderer/GPU/utility process, and showing a dialog from one of
278    // those would be wrong, and would multiply once per subprocess.
279    // `--type=` is the same subprocess-detection pattern already used
280    // twice further down in this function. No-op if the user already
281    // opted out themselves (their explicit choice takes precedence, and
282    // the probe would just be wasted work).
283    // See docs/specs/SPEC_LINUX_SANDBOX_APPARMOR_USERNS_2026_08_23.md.
284    #[cfg(all(target_os = "linux", feature = "sandbox"))]
285    {
286        let is_subprocess = std::env::args().any(|a| a.starts_with("--type="));
287        if !is_subprocess && !force_no_sandbox {
288            use linux_sandbox::SandboxDialogChoice;
289            // Loop rather than a single check-then-act: re-probes at the
290            // top of every iteration (so a successful relaunch — see
291            // FixNow below — falls straight through without a dialog),
292            // and a FAILED fix attempt loops back to re-show the dialog
293            // instead of silently downgrading (reagent P1 on PR #2783 —
294            // the user asked to FIX it, not to run without a sandbox;
295            // never auto-disable on their behalf).
296            while !linux_sandbox::probe_userns_available() {
297                match linux_sandbox::show_userns_blocked_dialog() {
298                    SandboxDialogChoice::FixNow => {
299                        let helper = linux_sandbox::resolve_helper_script_path();
300                        match linux_sandbox::run_pkexec_fix(&helper) {
301                            Ok(()) => {
302                                // AppArmor confinement attaches at exec()
303                                // time only — this already-running process
304                                // can never benefit from the
305                                // newly-installed profile no matter what
306                                // we do next (reagent P0 on PR #2783).
307                                // Relaunch in place so the ENTIRE process
308                                // tree (this process and every CEF
309                                // subprocess it spawns) starts fresh under
310                                // the new profile.
311                                eprintln!(
312                                    "AgentMux: sandbox fix installed — relaunching to apply it."
313                                );
314                                let exe = std::env::current_exe()
315                                    .unwrap_or_else(|_| std::path::PathBuf::from("agentmux-cef"));
316                                let args: Vec<String> = std::env::args().skip(1).collect();
317                                use std::os::unix::process::CommandExt;
318                                let err = std::process::Command::new(&exe).args(&args).exec();
319                                // exec() replaces the process image and
320                                // never returns on success — reaching here
321                                // means it failed.
322                                eprintln!(
323                                    "AgentMux: relaunch failed after installing the fix ({err}) \
324                                     — please start AgentMux again manually."
325                                );
326                                std::process::exit(1);
327                            }
328                            Err(e) => {
329                                eprintln!("AgentMux: sandbox fix failed: {e}");
330                                // Loop back to the dialog rather than
331                                // falling through unsandboxed.
332                            }
333                        }
334                    }
335                    SandboxDialogChoice::ContinueWithoutSandbox => {
336                        // SAFETY: single-threaded at this point in startup —
337                        // before any additional threads (async runtime,
338                        // CEF's own internal threads) have been spawned,
339                        // and gated to the browser process only
340                        // (subprocess roles never reach this branch) — no
341                        // concurrent env reader exists yet for this
342                        // mutation to race with.
343                        unsafe {
344                            std::env::set_var("AGENTMUX_UNSAFE_NOSANDBOX", "1");
345                        }
346                        force_no_sandbox = true;
347                        // Keep the downgrade visible for the rest of the
348                        // session (window title) — reagent/codex P2 on
349                        // PR #2783.
350                        linux_sandbox::RUNNING_UNSANDBOXED
351                            .store(true, std::sync::atomic::Ordering::Relaxed);
352                        break;
353                    }
354                    SandboxDialogChoice::Cancel => {
355                        std::process::exit(1);
356                    }
357                    SandboxDialogChoice::NoDialogToolAvailable => {
358                        eprintln!(
359                            "AgentMux: the sandbox is blocked by a system AppArmor policy \
360                             (common on newer Ubuntu), and no dialog tool (zenity/kdialog) is \
361                             available to ask how to proceed. Set AGENTMUX_UNSAFE_NOSANDBOX=1 \
362                             to run without the sandbox, or see docs/linux.md for how to \
363                             install the proper fix."
364                        );
365                        std::process::exit(1);
366                    }
367                }
368            }
369        }
370    }
371
372    // macOS sandbox availability: probe for libcef_sandbox.dylib to detect
373    // whether we are running inside a packaged .app bundle. Two uses:
374    //   1. Seatbelt init (subprocess only) — skip if dylib not reachable.
375    //   2. no_sandbox flag to CefInitialize — must be 1 when not in bundle,
376    //      otherwise CEF kills GPU/network/renderer with a sandbox error.
377    //
378    // The path is relative to current_exe().parent() and differs by role
379    // because the Host and Helper processes live at different depths:
380    //
381    //   Host   — Contents/MacOS/AgentMux
382    //            → ../Frameworks/Chromium Embedded Framework.framework/…
383    //
384    //   Helper — Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
385    //            → ../../../Chromium Embedded Framework.framework/…
386    //
387    // In task dev neither path exists (flat dist/cef-dev/ tree), so the probe
388    // returns false for both roles → sandbox skipped, no crash.
389    #[cfg(all(target_os = "macos", feature = "sandbox"))]
390    let macos_sandbox_available = {
391        let is_subprocess = std::env::args().any(|a| a.starts_with("--type="));
392        let rel: &str = if is_subprocess {
393            "../../../Chromium Embedded Framework.framework/Libraries/libcef_sandbox.dylib"
394        } else {
395            "../Frameworks/Chromium Embedded Framework.framework/Libraries/libcef_sandbox.dylib"
396        };
397        std::env::current_exe()
398            .ok()
399            .and_then(|p| p.parent().map(|d| d.join(rel)))
400            .map(|p| p.exists())
401            .unwrap_or(false)
402    };
403
404    // Non-macOS / sandbox-off: define macos_sandbox_available as a no-op
405    // placeholder so the no_sandbox computation below compiles on all targets.
406    // On those targets cfg!(all(target_os="macos",feature="sandbox")) is false,
407    // so the `&& !macos_sandbox_available` branch is never reached at runtime.
408    #[cfg(not(all(target_os = "macos", feature = "sandbox")))]
409    let macos_sandbox_available = true; // irrelevant — guarded by cfg!() in no_sandbox
410
411    // macOS: initialize Seatbelt sandbox context BEFORE the CEF framework is
412    // loaded. This is the order required by CEF (cefsimple/process_helper_mac.cc):
413    //   1. cef_sandbox_initialize     ← here, before dlopen
414    //   2. LoadInHelper / _library    ← CEF framework loaded within the sandbox
415    //   3. CefExecuteProcess
416    // Subprocess mode is detected from raw argv (--type=…) because cef::Args
417    // cannot be parsed until after the framework is loaded. Browser process
418    // skips this entirely — the host process is unsandboxed by design.
419    #[cfg(all(target_os = "macos", feature = "sandbox"))]
420    let _macos_sandbox = if std::env::args().any(|a| a.starts_with("--type="))
421        && !force_no_sandbox
422        && macos_sandbox_available
423    {
424        let raw = cef::args::Args::new();
425        let mut s = cef::sandbox::Sandbox::new();
426        s.initialize(raw.as_main_args());
427        Some(s)
428    } else {
429        None
430    };
431
432    // macOS: load the CEF framework library explicitly.
433    // For subprocesses this load happens within the Seatbelt policy established above.
434    //
435    // Timed locally (Instant, not IPC) because this happens before
436    // connect_to_launcher runs — there's no live launcher connection yet
437    // to report through. The duration is sent retroactively as soon as
438    // the connection exists (see the report_startup_stage_* call right
439    // after connect_to_launcher below). Reported for every process role
440    // that reaches here (browser + subprocesses), but only the browser
441    // process ever has a live COMMAND_TX to actually send through —
442    // report_startup_stage_* silently no-ops otherwise, so no extra
443    // role-gating is needed here.
444    #[cfg(target_os = "macos")]
445    let dlopen_started_at = std::time::Instant::now();
446    #[cfg(target_os = "macos")]
447    let _library = {
448        let exe = std::env::current_exe().unwrap();
449        // Inside a packaged .app, renderer/GPU/utility subprocesses run as the
450        // bundled "AgentMux Helper" at
451        // …/Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
452        // — 4 levels below the framework — so it must resolve the framework via
453        // the deeper `../../../../Frameworks/…` path (helper=true). The main
454        // host (Contents/MacOS/) and the bare dev binary use `../Frameworks/…`
455        // (helper=false). Detect the helper by its exe path; the main bundle
456        // binary is never under Contents/Frameworks/. See
457        // docs/specs/SPEC_MACOS_PACKAGING_2026_05_30.md.
458        let is_helper = exe.to_string_lossy().contains(".app/Contents/Frameworks/");
459        let loader = library_loader::LibraryLoader::new(&exe, is_helper);
460        assert!(loader.load(), "Failed to load CEF framework");
461        loader
462    };
463    #[cfg(target_os = "macos")]
464    let dlopen_ms = dlopen_started_at.elapsed().as_millis() as u64;
465
466    // Initialize the CEF API hash for version verification.
467    let _ = api_hash(sys::CEF_API_VERSION_LAST, 0);
468
469    // Parse command-line arguments.
470    let args = cef::args::Args::new();
471    let Some(cmd_line) = args.as_cmd_line() else {
472        eprintln!("agentmux-cef: Failed to parse command line arguments");
473        std::process::exit(1);
474    };
475
476    // Detect subprocess mode: CEF injects --type=renderer|gpu-process|utility
477    // for child processes. If --type is present, this is a subprocess.
478    let type_switch = CefString::from("type");
479    let is_browser_process = cmd_line.has_switch(Some(&type_switch)) != 1;
480
481    // macOS 26 + dev build: ChromeWebAppShortcutCopierMain CHECK-aborts (SIGTRAP)
482    // because the dev binary is not in a signed .app bundle.
483    // --disable-features=MacAppCodeSignClone in on_before_command_line_processing
484    // cannot prevent the spawn because the feature flag is checked before FeatureList
485    // is initialized (same timing problem as MachPortRendezvous). Exit 0 immediately
486    // for this subprocess type so the main process sees a "clean" failure and
487    // continues without the code-sign clone (which is irrelevant for dev builds).
488    // The main process logs "Failed to send Mojo invitation to web_app_shortcut_copier"
489    // (harmless) and continues. This exit must happen BEFORE execute_process() which
490    // calls ChromeWebAppShortcutCopierMain() and SIGTRAP-kills the subprocess.
491    #[cfg(target_os = "macos")]
492    if !is_browser_process {
493        let pt = CefString::from(&cmd_line.switch_value(Some(&type_switch)));
494        let pt_str = pt.to_string();
495        if pt_str == "web-app-shortcut-copier" {
496            eprintln!("agentmux-cef: intercepted web-app-shortcut-copier (dev build, not in .app bundle) — exiting 0");
497            std::process::exit(0);
498        }
499    }
500
501    let ret = execute_process( // runs a subprocess (exiting below); renderer_map.rs says why its app
502        Some(args.as_main_args()),
503        (!is_browser_process).then(renderer_map::SubprocessApp::new).as_mut(),
504        windows_sandbox_info as *mut u8,
505    );
506
507    if is_browser_process {
508        // Browser process: execute_process returns -1, we continue with initialization.
509        assert_eq!(ret, -1, "execute_process should return -1 for browser process");
510    } else {
511        // Subprocess: execute_process returns the exit code.
512        let process_type = CefString::from(&cmd_line.switch_value(Some(&type_switch)));
513        eprintln!("agentmux-cef: subprocess exiting: type={}", process_type);
514        assert!(ret >= 0, "execute_process failed for subprocess");
515        std::process::exit(ret);
516    }
517
518    // -----------------------------------------------------------------------
519    // Browser process initialization
520    // -----------------------------------------------------------------------
521
522    // Set the Application User Model ID before any UI is created. This lets
523    // Windows group our windows under one pinned identity and is required for
524    // the `DeleteTab` + per-HWND AppID treatment used by the full-instance /
525    // sub-window model (see docs/specs/SPEC_MULTIWINDOW_TASKBAR_GROUPING.md).
526    // Use a VERSION-STABLE ID — never embed the patch number or pinning forks.
527    #[cfg(target_os = "windows")]
528    unsafe {
529        use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
530        let aumid: Vec<u16> = "AgentMuxCorp.AgentMux\0".encode_utf16().collect();
531        let _ = SetCurrentProcessExplicitAppUserModelID(aumid.as_ptr());
532    }
533
534    let version = env!("CARGO_PKG_VERSION");
535
536    // Read paths + mode from the launcher-injected env vars. Two
537    // reachable configurations:
538    //   a) Launcher-managed startup → env vars present, from_env()
539    //      returns Some.
540    //   b) Standalone `task dev` → env absent. We re-derive via
541    //      `RuntimeMode::current` + `DataPaths::resolve` (symmetric
542    //      with sidecar.rs::spawn_backend's fallback so they agree on
543    //      the disk layout).
544    let host_exe_dir = std::env::current_exe()
545        .ok()
546        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
547        .unwrap_or_default();
548    // Dev builds NEVER inherit AGENTMUX_* env vars from a parent process.
549    // `task dev` is routinely launched from inside an AgentMux terminal
550    // pane, which means the child host inherits the parent instance's
551    // AGENTMUX_DATA_DIR pointing at the parent's version-isolated dir.
552    // Without this guard the dev build would resolve its data dir to the
553    // running portable's path and trip CEF's process-singleton lock,
554    // routing every "open" back to the existing window — the user would
555    // never see the dev code run. Path-based detection is authoritative
556    // for dev builds; for installed/portable we still honor the
557    // launcher-provided env (it's the launcher's job to publish them).
558    let common_paths = if agentmux_common::is_dev_build_exe(&host_exe_dir) {
559        let mode = agentmux_common::RuntimeMode::current_path_only(&host_exe_dir);
560        // resolve_path_only mirrors current_path_only's env-isolation:
561        // ignore inherited AGENTMUX_CHANNEL so a dev host launched from
562        // inside a parent agentmux instance doesn't redirect into the
563        // parent's channel (would trip the channel single-instance lock).
564        agentmux_common::DataPaths::resolve_path_only(version, &mode).ok()
565    } else {
566        agentmux_common::DataPaths::from_env().or_else(|| {
567            let mode = agentmux_common::RuntimeMode::current(&host_exe_dir);
568            agentmux_common::DataPaths::resolve(version, &mode).ok()
569        })
570    };
571    let is_dev = match &common_paths {
572        Some(p) => matches!(p.mode, agentmux_common::RuntimeMode::Dev { .. }),
573        None => false,
574    };
575
576    let (data_dir, log_dir) = match &common_paths {
577        Some(p) => (p.cef_cache_dir.clone(), p.logs_dir.clone()),
578        None => {
579            // Both env-read AND fallback resolution failed (no home
580            // dir on disk, or platform unsupported). Use a degraded
581            // path so log init at least works; the runtime-startup
582            // check below will surface the underlying error.
583            (
584                std::path::PathBuf::from("."),
585                dirs::home_dir()
586                    .unwrap_or_default()
587                    .join(".agentmux")
588                    .join("logs"),
589            )
590        }
591    };
592    std::fs::create_dir_all(&data_dir).ok();
593
594    // Initialize dual-output tracing: rolling log file + stderr.
595    // The log file guard must live for the entire process to ensure flushing.
596    let _log_guard = logging::init_logging(&log_dir);
597
598    tracing::info!(
599        version,
600        runtime_mode = ?common_paths.as_ref().map(|p| p.mode.to_env_string()),
601        data_dir = %data_dir.display(),
602        log_dir = %log_dir.display(),
603        "Initializing CEF browser process"
604    );
605
606    // macOS 26 Tahoe compat: CEF 146 calls private NSApplication selectors
607    // (e.g. `isHandlingSendEvent`, `isSendingEvent`) during NSDraggingSession
608    // setup. Apple removed them in macOS 26, so the calls go through
609    // `___forwarding___`, find nothing, and `objc_exception_throw` fires —
610    // AppKit's default uncaught-exception handler then calls `_objc_terminate`
611    // and the host dies (EXC_BREAKPOINT / SIGTRAP on CrBrowserMain). Inject
612    // `+[NSApplication resolveInstanceMethod:]` to add typed stubs *before*
613    // the forwarding machinery is entered. Must run before `cef::initialize`.
614    // See docs/specs/SPEC_MACOS_TEAROFF_STABILITY_2026_05_29.md and PR #403.
615    #[cfg(target_os = "macos")]
616    unsafe { macos_compat::patch_nsapp_unrecognized_selector() };
617
618    // macOS tear-off polish: suppress AppKit's native drag slide-back
619    // ("poof" / fly-back-to-origin) animation. When a pane/tab is torn
620    // off, the pointer is released OUTSIDE any DOM drop target (the new
621    // floating window is created on mouseup), so blink hands AppKit
622    // NSDragOperationNone and AppKit animates the drag image sliding
623    // back into the source window — the very "rejection" animation the
624    // tear-off is trying to avoid. `preventUnhandled` (PR #1186) only
625    // covers in-document drops, not this out-of-window case. Disable the
626    // session-level animation flag globally; we never want a
627    // drop-rejected slide-back anywhere in the app.
628    #[cfg(target_os = "macos")]
629    unsafe { macos_compat::disable_macos_drag_slideback() };
630
631    // Name the app early (sets CFBundleName on the main bundle, which AppKit
632    // may read when it first builds a menu). Also re-run post-init below, since
633    // Chromium can reset the process name during cef::initialize.
634    // NOTE: CFBundleIdentifier is NOT set here (pre-init) — setting it before
635    // cef::initialize triggers MacAppCodeSignClone which SIGTRAP-crashes dev builds.
636    #[cfg(target_os = "macos")]
637    unsafe { macos_compat::set_macos_app_display_name_pre_init() };
638
639    // Phase B.6 (post-fix): the named-pipe bind in the launcher is
640    // the AUTHORITATIVE single-instance lock — a second launcher
641    // hits ERROR_ACCESS_DENIED and never reaches the host. We still
642    // publish `<launcher-shared-data-dir>/ipc-port` (port:token) so
643    // the second launcher can FORWARD an `open_new_window` request
644    // to the existing instance over HTTP and exit silently — the
645    // legacy forwarding UX users expect when double-clicking the
646    // exe twice. The pipe-bind-first ordering closes the stale-state
647    // defect (gap #8 in
648    // docs/specs/ANALYSIS_WINDOW_PROCESS_STATE_INVENTORY_2026_04_27.md):
649    // a stale ipc-port file from a hard crash is irrelevant on the
650    // FIRST-instance path because pipe-bind succeeds and the file is
651    // overwritten; on the SECOND-instance path the live first
652    // instance wrote a fresh port:token, so forwarding lands.
653    //
654    // CRITICAL: write the port file at the LAUNCHER-shared data dir
655    // (`AGENTMUX_DATA_DIR`, == `paths.data_dir` in the launcher), NOT
656    // the host-local CEF cache dir (`<portable>/data/cef/`). The two
657    // diverge in portable mode (cef cache is one level deeper) and
658    // the launcher's `forward_open_new_window` reads the launcher-
659    // shared path. Falls back to the cef cache dir only when the env
660    // is unset (`task dev` mode without launcher), where forwarding
661    // wouldn't be wired anyway.
662    // Dev builds inherit AGENTMUX_DATA_DIR from the parent pane they were
663    // launched from. Writing ipc-port there would overwrite the parent
664    // instance's port:token and break its single-instance forwarding.
665    // That hazard is real ONLY when no launcher spawned us; see
666    // `port_file_dir` for why the launcher's presence is the deciding
667    // factor rather than "is this a dev build".
668    let port_file_dir = port_file_dir(
669        std::env::var_os("AGENTMUX_IPC_HASH").is_some_and(|h| !h.is_empty()),
670        std::env::var_os("AGENTMUX_DATA_DIR").map(std::path::PathBuf::from),
671        agentmux_common::is_dev_build_exe(&host_exe_dir),
672        &data_dir,
673    );
674    let _ = std::fs::create_dir_all(&port_file_dir);
675    // Use a version-scoped filename so two concurrent release versions
676    // don't overwrite each other's port file (codex P1 on #1227).
677    // AGENTMUX_IPC_HASH is set by the launcher to hash(data_dir, version).
678    let port_file_name = std::env::var("AGENTMUX_IPC_HASH")
679        .ok()
680        .filter(|h| !h.is_empty())
681        .map(|h| format!("ipc-port-{}", h))
682        .unwrap_or_else(|| "ipc-port".to_string());
683    let port_file = port_file_dir.join(&port_file_name);
684
685    // Create shared application state.
686    let app_state = Arc::new(state::AppState::default());
687
688    // Start tokio runtime for async operations (IPC server, sidecar management).
689    let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
690
691    // Install the runtime Handle into browser_pane::auth so the
692    // CEF `get_auth_credentials` callback (which runs on CEF's IO
693    // thread) can spawn the parked-auth TTL timer. A bare
694    // `tokio::spawn` there would panic with "there is no reactor
695    // running" because that thread has no `Handle::current()`.
696    browser_pane::auth::set_runtime_handle(runtime.handle().clone());
697
698    // Same rationale, for the credential broker's async orchestration
699    // (and the approval registry's TTL timer, which shares this handle) —
700    // see `credential_broker::set_runtime_handle`'s doc comment.
701    credential_broker::set_runtime_handle(runtime.handle().clone());
702
703    // Lets the approval registry's TTL timer close an expired approval's
704    // subwindow. It holds `window_id` strings and nothing else by design, and
705    // its timer task has neither `AppState` nor a window handle — without
706    // this, a timed-out approval left a dead, unresponsive window open until
707    // the human closed it by hand (reagent P2 on PR #2824).
708    {
709        let closer_state = app_state.clone();
710        credential_broker::set_window_closer(move |window_id: &str| {
711            if let Err(e) = commands::window::close_window_by_label(
712                &closer_state,
713                &serde_json::json!({ "label": window_id }),
714            ) {
715                tracing::warn!("[credential-broker] failed to close approval window {window_id}: {e}");
716            }
717        });
718    }
719
720    // Start the IPC HTTP server and get the assigned port.
721    let ipc_port = runtime.block_on(ipc::start_ipc_server(app_state.clone()));
722    *app_state.ipc_port.lock() = ipc_port;
723
724    tracing::info!("IPC server started on port {}", ipc_port);
725
726    // Phase B.2: connect to launcher's named-pipe IPC (if launcher
727    // is in the loop) so the launcher can route Commands and Events
728    // through us. The handle is held in main scope for the host's
729    // lifetime — dropping it closes the pipe (logged by launcher).
730    // Failure to connect is non-fatal in B.2 (host can still run);
731    // B.5+ will tighten when the host depends on IPC for state.
732    //
733    // Env-isolation guard: a dev build inheriting
734    // `AGENTMUX_LAUNCHER_PIPE` from a parent AgentMux pane (e.g. a
735    // shell inside an active pane that re-invokes the host directly)
736    // would otherwise connect to that parent's launcher pipe and
737    // route its host events into the parent's launcher state.
738    //
739    // Discriminator: connect when our parent process IS the launcher
740    // (production portable, installed build, OR post-#SPEC_LAUNCHER_DEV_INTEGRATION
741    // `task dev` which spawns the host via the launcher). Skip when
742    // it isn't.
743    //
744    // Older path-only guard (`is_dev_build_exe`) over-fired in dev
745    // mode after launcher integration shipped — see
746    // docs/specs/SPEC_DEV_MODE_LAUNCHER_IPC_2026_05_16.md.
747    let parent_is_launcher = parent_process::parent_is_agentmux_launcher();
748    let should_connect_launcher = match parent_is_launcher {
749        Some(true) => true,
750        Some(false) => false,
751        // Parent detection failed — fall back to the path-based guard
752        // so production builds still connect (they would otherwise
753        // silently lose the launcher IPC) and dev builds still skip.
754        None => !agentmux_common::is_dev_build_exe(&host_exe_dir),
755    };
756    let _launcher_ipc = if should_connect_launcher {
757        runtime.block_on(launcher_ipc::connect_to_launcher(app_state.clone()))
758    } else {
759        None
760    };
761
762    // Report the CEF framework dlopen timing now that a launcher
763    // connection exists (it didn't when dlopen actually happened, above).
764    // Sent as a StageBegin+StageEnd pair with the already-elapsed
765    // duration on the End message — the launcher's splash renders this
766    // like any other completed stage. No-op if _launcher_ipc is None
767    // (dev:standalone, or this is a subprocess role that never connects).
768    #[cfg(target_os = "macos")]
769    {
770        launcher_ipc::report_startup_stage_begin("dlopen", "Load CEF framework");
771        launcher_ipc::report_startup_stage_end("dlopen", dlopen_ms, "ok", None);
772    }
773
774    // Phase E.2c.5a — connect to the srv reducer's pipe. Forwards
775    // srv events (workspace / tab / block lifecycle) to every
776    // top-level renderer via the JS bridge. Renderer-side handler
777    // (`window.__agentmux_srv_event`) lands in E.2c.5b. Non-fatal
778    // if absent: `AGENTMUX_SRV_PIPE_PATH` is only set on the srv
779    // child by the launcher (`crates/launcher/src/srv_spawner.rs`),
780    // not on the host spawn — so today the host never has the env
781    // var and `connect_to_srv` (`srv_ipc.rs`) short-circuits to None at
782    // its env-var check. Path-based dev guard is the right gate
783    // for this branch; restoring full srv-IPC parity in dev needs
784    // the launcher to propagate the env var to the host first.
785    // See spec §11 of SPEC_DEV_MODE_LAUNCHER_IPC_2026_05_16.md.
786    let _srv_ipc = if agentmux_common::is_dev_build_exe(&host_exe_dir) {
787        None
788    } else {
789        runtime.block_on(srv_ipc::connect_to_srv(app_state.clone()))
790    };
791
792    // Phase B.1: if launcher already spawned srv (the normal portable
793    // / installed path post-PR-#570 + B.1), populate state from the
794    // env vars launcher set — no need to re-spawn srv. Falls back to
795    // spawn_backend() ONLY when env vars are absent (`task dev` mode
796    // where the host runs without the launcher).
797    //
798    // Spawn the backend sidecar SYNCHRONOUSLY — block until it
799    // signals ready (AGENTMUXSRV-ESTART) before creating the browser
800    // window. This eliminates the race condition where CEF loads the
801    // frontend before the backend is available, which causes a "raw
802    // browser" appearance on slow machines or first launch.
803    let backend_ready = runtime.block_on(async {
804        // Dev builds inherit AGENTMUX_BACKEND_WS from the parent pane.
805        // Consuming it would connect to the parent's srv instead of
806        // spawning our own, so the dev frontend runs against the wrong
807        // (parent's) backend and no dev-version srv is ever started.
808        //
809        // EXCEPTION (launcher-driven `task dev` on every platform —
810        // SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30 for macOS/Linux,
811        // Windows since #3868's follow-up): when a launcher
812        // is our GENUINE parent this run (it stamped AGENTMUX_LAUNCHER_PID
813        // with its pid == our getppid), the env it set is fresh + ours —
814        // adopt its launcher-owned srv instead of double-spawning. The
815        // getppid match can't be satisfied by a value merely inherited
816        // down the env from a parent pane, so `task dev:standalone`
817        // (direct host invoke) still spawns its own srv.
818        let launcher_provided = if agentmux_common::is_dev_build_exe(&host_exe_dir)
819            && !launcher_is_genuine_parent()
820        {
821            None
822        } else {
823            sidecar::use_launcher_endpoints(&app_state)
824        };
825        // Captured before the match below consumes launcher_provided by value.
826        // Phase 0f: distinguishes which path result.pending_migrations came
827        // from, so we know when it's safe to trust a 0.
828        let is_host_owned_spawn = launcher_provided.is_none();
829        let result = match launcher_provided {
830            Some(Ok(r)) => {
831                tracing::info!(
832                    "Using launcher-provided backend endpoints: ws={} web={} pid={}",
833                    r.ws_endpoint,
834                    r.web_endpoint,
835                    r.instance_id
836                );
837                Ok(r)
838            }
839            Some(Err(e)) => {
840                tracing::error!(
841                    "Launcher set AGENTMUX_BACKEND_WS but env was malformed: {} — refusing to fall back to spawn_backend (would fight launcher's srv)",
842                    e
843                );
844                Err(e)
845            }
846            None => {
847                tracing::info!("No launcher-provided backend env (dev mode) — spawning srv ourselves");
848                sidecar::spawn_backend(&app_state).await
849            }
850        };
851        match result {
852            Ok(result) => {
853                {
854                    let mut endpoints = app_state.backend_endpoints.lock();
855                    endpoints.ws_endpoint = result.ws_endpoint.clone();
856                    endpoints.web_endpoint = result.web_endpoint.clone();
857                }
858                // On the host-owned spawn_backend path, result.pending_migrations
859                // is the authoritative signal for THIS spawn — always trust it,
860                // including 0, so a stale nonzero count cached from an earlier
861                // spawn in this process's lifetime (e.g. before a restart) gets
862                // cleared on a clean re-spawn instead of sticking forever
863                // (Phase 0f, docs/specs/SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03.md
864                // §1.6/F8). On the launcher path use_launcher_endpoints always
865                // returns 0 here (meaningless stub) and the real count was
866                // already seeded into AppState from AGENTMUX_PENDING_MIGRATIONS
867                // at construction time — only ever overwrite it with a
868                // genuinely positive value, never clobber it with this stub 0.
869                if is_host_owned_spawn || result.pending_migrations > 0 {
870                    *app_state.pending_migrations.lock() = result.pending_migrations;
871                }
872                tracing::info!(
873                    "Backend ready: ws={} web={}",
874                    result.ws_endpoint,
875                    result.web_endpoint
876                );
877                true
878            }
879            Err(e) => {
880                tracing::error!("Failed to set up backend: {}", e);
881                false
882            }
883        }
884    });
885
886    if !backend_ready {
887        tracing::error!("Backend failed to start — exiting");
888        std::process::exit(1);
889    }
890
891    // Dev-only: write authkey.dev so external test harnesses can call
892    // the service API without polling logs or driving the UI. Gate is
893    // Pick a FREE remote-debugging port instead of a fixed one: AgentMux runs
894    // many instances in parallel (isolation I1–I6), so a hardcoded port collides
895    // (WSAEADDRINUSE / 0x2740) and the 2nd+ instance gets no DevTools server →
896    // the browser DOM API (`/agentmux/browser/*`) can't connect. Prefer the
897    // conventional port (9223 dev / 9222 release) for muscle memory; fall back to
898    // an OS-assigned free port. Store the ACTUAL port so `browser_api` targets
899    // it. SPEC_CEF_LOG_ROBUSTNESS_2026_06_20.md §2.
900    //
901    // Off entirely in release builds unless AGENTMUX_CDP_PORT opts in
902    // (#3681): the server is unauthenticated on a loopback every local user
903    // shares, and nothing in the app needs it — the browser API is in-process
904    // now. `debug_port == 0` means "no server"; CEF treats a 0
905    // `remote_debugging_port` the same way. See `cdp_port`.
906    let requested = cdp_port::requested_cdp_port(
907        is_dev,
908        std::env::var(cdp_port::CDP_PORT_ENV).ok().as_deref(),
909    );
910    let debug_port: u16 = match requested {
911        None => 0,
912        Some(preferred) => {
913            use std::net::TcpListener;
914            if TcpListener::bind(("127.0.0.1", preferred)).is_ok() {
915                preferred
916            } else {
917                TcpListener::bind(("127.0.0.1", 0))
918                    .and_then(|l| l.local_addr())
919                    .map(|a| a.port())
920                    .unwrap_or(preferred)
921            }
922        }
923    };
924    *app_state.debug_port.lock() = debug_port;
925    match requested {
926        Some(preferred) => tracing::info!("CEF remote-debugging port: {} (preferred {})", debug_port, preferred),
927        None => tracing::info!(
928            "CEF remote-debugging server off (set {}=<port> to enable it)",
929            cdp_port::CDP_PORT_ENV
930        ),
931    }
932
933    // Resolved BEFORE authkey.dev is written, so the file can publish it.
934    // Tooling used to hardcode 9222/9223, which silently targets whichever
935    // instance won the race rather than the one the caller meant — an agent
936    // attached a debugger to a DIFFERENT running instance that way. A
937    // well-known constant cannot identify an instance; the per-instance file
938    // can. See docs/specs/SPEC_INSTANCE_DISCOVERY_FOR_TOOLING_2026_09_17.md.
939    // Write authkey.dev for ALL runtime modes (dev, portable, installed).
940    // The file lets bench-term-echo.mjs and the PowerShell test harnesses
941    // discover the running instance without manual --ws-url / --auth-key flags.
942    // Security: the WS server is loopback-only; any same-user process already
943    // has equivalent TCP access. See SPEC_TEST_API_ACCESS.md §3 and
944    // SPEC_BENCHMARK_PORTABLE_DISCOVERY_2026_05_20.md for rationale.
945    {
946        let endpoints = app_state.backend_endpoints.lock().clone();
947        let auth_key = app_state.auth_key.lock().clone();
948        let ipc_token = app_state.ipc_token.clone();
949        let data_dir_str = app_state
950            .version_data_dir
951            .lock()
952            .clone()
953            .unwrap_or_default();
954        let data_dir_path = std::path::PathBuf::from(&data_dir_str);
955        let ipc_endpoint = format!("127.0.0.1:{}", ipc_port);
956        let instance = format!("v{}", env!("CARGO_PKG_VERSION"));
957        let host_pid = std::process::id();
958        match dev_authfile::write_dev_auth_file(
959            &data_dir_path,
960            &auth_key,
961            &endpoints.web_endpoint,
962            &endpoints.ws_endpoint,
963            &ipc_endpoint,
964            &ipc_token,
965            &instance,
966            host_pid,
967            debug_port,
968        ) {
969            Ok(p) => tracing::info!("Wrote authkey file: {}", p.display()),
970            Err(e) => tracing::warn!("Failed to write authkey file: {}", e),
971        }
972    }
973
974    // Push this host's ipc_port/ipc_token to srv (host_ipc.Register) so
975    // srv can proxy /api/v1/ui/{screenshot,click,query} (agent-facing UI
976    // automation) to this host's /agentmux/browser/* routes. Must run
977    // after backend_endpoints.web_endpoint is known (just above) — see
978    // SPEC_AGENT_UI_AUTOMATION_CLICK_SCREENSHOT_2026_08_18.md. Runs off
979    // the UI thread via spawn_blocking (raw-TCP, synchronous, same as
980    // every other client::helpers call) so a slow/unreachable srv can't
981    // stall startup.
982    {
983        let web_endpoint = app_state.backend_endpoints.lock().web_endpoint.clone();
984        let auth_key = app_state.auth_key.lock().clone();
985        let ipc_token = app_state.ipc_token.clone();
986        let host_reg_secret = app_state.host_reg_secret.lock().clone();
987        runtime.spawn_blocking(move || {
988            client::register_ipc_with_backend(&web_endpoint, &auth_key, ipc_port, &ipc_token, &host_reg_secret);
989        });
990    }
991
992    // Create the App handler with state.
993    let mut cef_app = app::AgentMuxApp::new(app_state.clone(), ipc_port);
994
995    // Resolve resource directories for portable layout. In portable
996    // mode the CEF host is IN runtime/, so resources are flat
997    // alongside it. In dev mode they are also flat in dist/cef-dev/.
998    // Reuses `host_exe_dir` from the startup mode-detection block.
999    //
1000    // macOS is the exception: Chromium ships icudtl.dat + the .pak
1001    // files + locales inside the framework bundle's `Resources/`
1002    // directory, not flat next to the binary. cef-rs's LibraryLoader
1003    // already finds the framework via `../Frameworks/...`; we point
1004    // CefSettings at the same place so Chromium's icu_util loader,
1005    // pack loader, and locale loader resolve correctly. See
1006    // docs/specs/SPEC_MACOS_CEF_FRAMEWORK_BUNDLING_2026_05_28.md.
1007    let runtime_dir = host_exe_dir.join("runtime");
1008    let base_dir = if runtime_dir.exists() {
1009        runtime_dir
1010    } else {
1011        host_exe_dir.clone()
1012    };
1013    #[cfg(not(target_os = "macos"))]
1014    let (resources_path, locales_path) = (base_dir.clone(), base_dir.join("locales"));
1015    #[cfg(target_os = "macos")]
1016    let (framework_path, resources_path, locales_path) = {
1017        let framework = base_dir
1018            .join("..")
1019            .join("Frameworks")
1020            .join("Chromium Embedded Framework.framework");
1021        let resources = framework.join("Resources");
1022        (framework, resources.clone(), resources)
1023    };
1024    let resources_dir = CefString::from(resources_path.to_str().unwrap_or(""));
1025    let locales_dir = CefString::from(locales_path.to_str().unwrap_or(""));
1026    #[cfg(target_os = "macos")]
1027    let framework_dir = CefString::from(framework_path.to_str().unwrap_or(""));
1028
1029    // Reuse data_dir from single-instance check as CEF cache path.
1030    // Remove stale lockfile from a previous killed run.
1031    let lockfile = data_dir.join("lockfile");
1032    if lockfile.exists() {
1033        tracing::warn!("Removing stale CEF lockfile: {}", lockfile.display());
1034        let _ = std::fs::remove_file(&lockfile);
1035    }
1036    tracing::info!("CEF cache dir: {}", data_dir.display());
1037    let cache_dir = CefString::from(data_dir.to_str().unwrap_or(""));
1038    // Expose root_cache_path. Secondary windows share the default context
1039    // (`commands::secondary_window_request_context`) and create nothing under
1040    // it, but the legacy-litter sweep below and future consumers still need
1041    // the resolved path.
1042    *app_state.cef_cache_dir.lock() = Some(data_dir.to_string_lossy().to_string());
1043    // Remove dirs left by the earlier isolated-context path schemes
1044    // (`browser-contexts/`, `ctx-*`) — labels are per-run UUIDs, so they can
1045    // never be referenced again. SPEC_CEF_LOG_ROBUSTNESS_2026_06_20.md §1.6.
1046    crate::commands::cleanup_legacy_context_dirs(&data_dir.to_string_lossy());
1047
1048    // Configure CEF settings.
1049
1050    // Route CEF's internal Chromium logging into our log dir alongside the
1051    // tracing-subscriber file. Without this, init failures leave an empty
1052    // chrome_debug.log in the cache dir and we have nothing to read. INFO is
1053    // verbose enough to expose load-library / resource problems but quiet
1054    // enough not to swamp the file in normal operation.
1055    let cef_log_path = log_dir.join("cef-debug.log");
1056    let cef_log_file = CefString::from(cef_log_path.to_str().unwrap_or(""));
1057
1058    if force_no_sandbox {
1059        tracing::warn!(
1060            "AGENTMUX_UNSAFE_NOSANDBOX=1: renderer sandbox disabled. \
1061             Only use in environments where namespace/Seatbelt sandbox is known-incompatible."
1062        );
1063    }
1064
1065    // Sandbox active on macOS (Seatbelt via libcef_sandbox.dylib), Linux
1066    // (kernel namespace isolation), and Windows (bootstrap.exe DLL wrapper,
1067    // Phase 3) when built with `--features sandbox` (the default).
1068    // Escape hatch: AGENTMUX_UNSAFE_NOSANDBOX=1 disables on all platforms.
1069    let no_sandbox: i32 = if cfg!(not(feature = "sandbox"))
1070        || force_no_sandbox
1071        || cfg!(all(target_os = "macos", feature = "sandbox")) && !macos_sandbox_available
1072    {
1073        1
1074    } else {
1075        0
1076    };
1077
1078    let settings = Settings {
1079        no_sandbox,
1080        // ARGB: alpha=0 → SK_AlphaTRANSPARENT → triggers the transparency
1081        // cascade in the patched libcef.so (see cef commits b921ffe18 +
1082        // 68e0dc668). The CSS layer's rgba(_,_,_,<1) body bg then composites
1083        // with the desktop instead of being clamped to opaque white.
1084        // Pair: BrowserSettings.background_color must also be 0 (app/mod.rs
1085        // sets it so only for alpha-capable windows).
1086        // Pair: WindowDelegate must return is_frameless=true (already does
1087        // for the main window).
1088        // Spec: docs/research/cef-transparency-research-2026-05-10.md.
1089        background_color: 0x00000000,
1090        remote_debugging_port: debug_port as i32,
1091        root_cache_path: cache_dir,
1092        resources_dir_path: resources_dir,
1093        locales_dir_path: locales_dir,
1094        // macOS-only: tell CEF where the framework bundle lives so it can
1095        // resolve icudtl.dat, *.pak, and helper-process binaries through
1096        // NSBundle. Without this, Chromium's icu_util loader looks via
1097        // [NSBundle mainBundle] (the host exe's pseudo-bundle) which has
1098        // no Resources/ and fails with "icudtl.dat not found in bundle".
1099        #[cfg(target_os = "macos")]
1100        framework_dir_path: framework_dir,
1101        log_file: cef_log_file,
1102        log_severity: LogSeverity::INFO,
1103        // CEF subprocess (renderer, GPU, utility) executable. On a packaged
1104        // macOS .app this is the dedicated "AgentMux Helper" (distinct bundle
1105        // id, LSUIElement) — re-execing the main bundle binary is rejected by
1106        // the macOS process model and the renderers crash-loop. In dev (bare
1107        // binary, no Helper.app) and on Windows/Linux it's the current exe
1108        // (self-reexec, which works there). See
1109        // docs/specs/SPEC_MACOS_PACKAGING_2026_05_30.md.
1110        browser_subprocess_path: CefString::from(resolve_browser_subprocess_path().as_str()),
1111        ..Default::default()
1112    };
1113
1114    // Initialize CEF.
1115    //
1116    // CefInitialize returns 1 on success and 0 either on real init failure OR
1117    // on "normal early exit" (process singleton, command-line forward, etc).
1118    // We can only tell the two apart by calling cef_get_exit_code() and
1119    // matching against cef_resultcode_t. Treat NORMAL_EXIT* codes as a clean
1120    // exit; everything else is a real failure that we surface via panic.
1121    //
1122    // Common early-exit codes (cef_resultcode_t):
1123    //   0  CEF_RESULT_CODE_NORMAL_EXIT
1124    //   24 CEF_RESULT_CODE_NORMAL_EXIT_PROCESS_NOTIFIED  ← singleton relaunch
1125    //   36 CEF_RESULT_CODE_NORMAL_EXIT_PACK_EXTENSION_SUCCESS
1126    //   38 CEF_RESULT_CODE_NORMAL_EXIT_AUTO_DE_ELEVATED
1127    // Live-reportable on every platform: connect_to_launcher (above)
1128    // always runs before cef::initialize, so a launcher connection
1129    // exists by this point wherever one exists at all. No-op if it
1130    // doesn't (dev:standalone, subprocess roles).
1131    let cef_init_started_at = std::time::Instant::now();
1132    launcher_ipc::report_startup_stage_begin("cef_init", "CEF initialize");
1133    let init_result = initialize(
1134        Some(args.as_main_args()),
1135        Some(&settings),
1136        Some(&mut cef_app),
1137        windows_sandbox_info as *mut u8,
1138    );
1139    if init_result != 1 {
1140        let exit_code = get_exit_code();
1141        // Sidecar was spawned before cef_initialize(); std::process::exit()
1142        // bypasses the normal shutdown block, so kill it here first.
1143        {
1144            let mut sidecar = app_state.sidecar_child.lock();
1145            if let Some(ref mut child) = *sidecar {
1146                tracing::info!("CEF early exit: killing backend sidecar before exit");
1147                let _ = child.kill();
1148            }
1149        }
1150        match exit_code {
1151            0 | 24 | 36 | 38 => {
1152                // `data_dir` (logged once already, above, as "CEF cache
1153                // dir") is the CEF profile directory whose own
1154                // SingletonLock is the most common real cause of this path
1155                // (exit_code 24 specifically = NORMAL_EXIT_PROCESS_NOTIFIED,
1156                // i.e. singleton relaunch) — printing it here turns "why did
1157                // this silently exit" into something directly inspectable
1158                // (e.g. `fuser`/`lsof` on this path) instead of requiring a
1159                // source read to rediscover it. See
1160                // docs/specs/SPEC_DEV_INSTANCE_ISOLATION_DIAGNOSTICS_2026_09_19.md.
1161                tracing::info!(
1162                    exit_code,
1163                    data_dir = %data_dir.display(),
1164                    "CEF early exit (process singleton or similar) — exiting cleanly"
1165                );
1166                std::process::exit(0);
1167            }
1168            _ => {
1169                tracing::error!(
1170                    exit_code,
1171                    "CEF initialization failed; see ~/.agentmux/logs/cef-debug.log for details"
1172                );
1173                // Surface a user-facing error instead of a silent splash-then-exit.
1174                // The most common cause is a bundled CEF runtime whose version does
1175                // not match the linked `cef` crate (e.g. a stale libcef.dll) — that
1176                // path logs "Request for unsupported CEF API version NNNNN" to
1177                // cef-debug.log and would otherwise just vanish after the splash.
1178                // See docs/specs/SPEC_WINDOWS_CEF_BUNDLE_VERSION_INTEGRITY_2026_06_03.md.
1179                #[cfg(target_os = "windows")]
1180                {
1181                    use windows_sys::Win32::UI::WindowsAndMessaging::{
1182                        MessageBoxW, MB_ICONERROR, MB_OK,
1183                    };
1184                    let title: Vec<u16> =
1185                        "AgentMux — startup failed\0".encode_utf16().collect();
1186                    let body = format!(
1187                        "AgentMux couldn't start its browser engine (CEF init failed, code {}).\n\n\
1188                         This usually means the bundled browser runtime is incompatible with this \
1189                         build — for example a stale or mismatched libcef.dll from an incomplete build.\n\n\
1190                         Details were written to:\n    %USERPROFILE%\\.agentmux\\logs\\cef-debug.log\n\n\
1191                         If you built this locally, run:  task clean:cef && task build:host",
1192                        exit_code
1193                    );
1194                    let body_w: Vec<u16> =
1195                        body.encode_utf16().chain(std::iter::once(0)).collect();
1196                    // SAFETY: null parent HWND with valid NUL-terminated wide strings.
1197                    unsafe {
1198                        MessageBoxW(
1199                            std::ptr::null_mut(),
1200                            body_w.as_ptr(),
1201                            title.as_ptr(),
1202                            MB_OK | MB_ICONERROR,
1203                        );
1204                    }
1205                }
1206                // Linux sibling of the Windows MessageBoxW block above — same
1207                // reasoning: the web frontend can't render an error here since
1208                // the host itself failed to start. This is the safety net for
1209                // CEF init failures OTHER than the userns-sandbox-blocked case
1210                // (that one is caught proactively, before ever reaching this
1211                // init call — see the probe near the top of this function).
1212                // Uses zenity/kdialog like linux_sandbox's dialog, but this is
1213                // a plain info dialog (no privileged fix to offer here, unlike
1214                // the userns case — this covers genuinely unknown failures).
1215                #[cfg(target_os = "linux")]
1216                {
1217                    let body = format!(
1218                        "AgentMux couldn't start its browser engine (CEF init failed, code {exit_code}).\n\n\
1219                         Details were written to:\n    ~/.agentmux/logs/cef-debug.log\n\n\
1220                         If this mentions a sandbox/userns/AppArmor error, see docs/linux.md."
1221                    );
1222                    let title = "AgentMux — startup failed";
1223                    let shown = std::process::Command::new("zenity")
1224                        .args(["--error", "--title", title, "--text", &body])
1225                        .status()
1226                        .map(|s| s.success() || s.code() == Some(1)) // zenity --error exits 1 on plain dismiss
1227                        .unwrap_or(false)
1228                        || std::process::Command::new("kdialog")
1229                            .args(["--title", title, "--error", &body])
1230                            .status()
1231                            .map(|s| s.success())
1232                            .unwrap_or(false);
1233                    if !shown {
1234                        eprintln!("{title}\n\n{body}");
1235                    }
1236                }
1237                std::process::exit(exit_code);
1238            }
1239        }
1240    }
1241
1242    launcher_ipc::report_startup_stage_end(
1243        "cef_init",
1244        cef_init_started_at.elapsed().as_millis() as u64,
1245        "ok",
1246        None,
1247    );
1248    tracing::info!("CEF initialized, entering message loop");
1249
1250    // Claim a Dock tile, THEN paint the icon onto it. A bare Mach-O launched
1251    // outside an `.app` bundle (both `task dev` direct-invoke and the launcher
1252    // flat layout) defaults to a background/accessory activation policy — no
1253    // Dock tile, no menu bar — so the AgentMux instance never shows in the
1254    // taskbar and the icon set below has nothing to land on. Force
1255    // NSApplicationActivationPolicyRegular first so the instance is a normal,
1256    // Dock-visible app. macOS-only; no-op elsewhere. Order matters: policy
1257    // before icon.
1258    #[cfg(target_os = "macos")]
1259    unsafe {
1260        // Friendly Dock + app-menu name ("AgentMux DEV" in dev) instead of the
1261        // raw process name "agentmux-cef". Done POST-init: Chromium overwrites
1262        // the process name during cef::initialize, so a pre-init set didn't
1263        // stick — set it here, right before the activation policy + our menu
1264        // bar are established.
1265        macos_compat::set_macos_app_display_name();
1266        macos_compat::set_macos_activation_policy_regular();
1267        macos_compat::set_macos_dock_icon();
1268        // Layer 1 of SPEC_MACOS_ACCESSIBILITY_ROBUSTNESS_2026-06-03: govern
1269        // Chromium's macOS accessibility activation so a window manager / KVM
1270        // poking the AX tree (Magnet, Synergy, …) can't force the crash-prone
1271        // web-content AX mode (CEF #3512). Must run after cef::initialize so
1272        // the CEF NSApplication subclass (which owns the legacy AX setter)
1273        // exists.
1274        macos_compat::install_macos_accessibility_governor();
1275    }
1276
1277    // Native macOS menu bar (File/Edit/View/Window/Help) — Phase 1 of
1278    // SPEC_MACOS_NATIVE_MENU_BAR_2026-06-03. After cef::initialize (NSApplication
1279    // exists) and after set_macos_app_display_name (the app-menu title follows
1280    // the process name). Standard Edit/Window items route to the focused web
1281    // view; custom items dispatch through the frontend command registry.
1282    #[cfg(target_os = "macos")]
1283    macos_menu::install_menu_bar(app_state.clone());
1284
1285    // Reopen handler — a plain re-launch / Finder/Dock double-click of the
1286    // running app opens a new window (Windows-parity) instead of just focusing
1287    // it. Installs an NSApplication delegate (`applicationShouldHandleReopen:`);
1288    // a raw NSAppleEventManager handler was inert because CEF re-registers its
1289    // own. After menu install, NSApplication exists.
1290    // SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
1291    #[cfg(target_os = "macos")]
1292    macos_menu::install_reopen_handler(app_state.clone());
1293
1294    // Start memory heartbeat — logs system/process memory stats every 20s.
1295    // Provides forensic data if the process later crashes from OOM / VA
1296    // exhaustion, and drives the debounced mem_pressure level + low-memory
1297    // banner (SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16 §5.A/§5.F).
1298    memory_heartbeat::start(app_state.clone());
1299
1300    // Phase B.6 (post-fix): publish port:token AFTER CEF init so a
1301    // second launcher only forwards `open_new_window` when we're
1302    // actually ready to handle it. Single-instance enforcement is
1303    // the launcher's named-pipe bind — this file is purely a
1304    // forwarding hint.
1305    let _ = std::fs::write(
1306        &port_file,
1307        format!("{}:{}", ipc_port, app_state.ipc_token),
1308    );
1309
1310    // Phase B.9.1 (WRR) — install Win32 event hooks. Must come
1311    // AFTER `connect_to_launcher` so the report_hwnd_* sync APIs
1312    // have a live `COMMAND_TX` to push into; AFTER CEF init so
1313    // any HWNDs CEF creates during initialize() are missed
1314    // (acceptable — they predate the user's session and are
1315    // accounted for by main-window startup paths). Idempotent;
1316    // safe to call multiple times. State arg lets the callback
1317    // peek `pending_window_creations` for `label_hint`.
1318    wrr::install_hooks(app_state.clone());
1319
1320    // Run the CEF message loop. This blocks until quit_message_loop() is called
1321    // (triggered when all browser windows are closed in client.rs).
1322    run_message_loop();
1323
1324    tracing::info!("CEF message loop exited, shutting down");
1325
1326    // Kill the backend sidecar. (The launcher's Job Object also reaps it once
1327    // we exit; kill explicitly for promptness.)
1328    {
1329        let mut sidecar = app_state.sidecar_child.lock();
1330        if let Some(ref mut child) = *sidecar {
1331            tracing::info!("Killing backend sidecar");
1332            let _ = child.kill();
1333        }
1334    }
1335
1336    // Phase B.6 (post-fix): clean up the forwarding hint so a stale file doesn't
1337    // survive a graceful exit. (Hard crashes leave it behind; harmless because
1338    // pipe-bind on next launch is authoritative.)
1339    let _ = std::fs::remove_file(&port_file);
1340
1341    // Reaching here means run_message_loop() returned, which ONLY happens on the
1342    // intended LastWindowClosed quit — so a hard exit(0) is correct here, NOT a
1343    // crash. It is also NECESSARY. After a CEF Views last-window close the
1344    // browsers are HIDDEN/recycled (the close never fires on_before_close), so
1345    // they're still alive at shutdown; CEF's teardown then access-violates on
1346    // Windows (`cef::shutdown()` / `UnhookWinEvent`, exit 0xC0000005) and wedges
1347    // in the tokio runtime drop on macOS. Either way the launcher (which
1348    // classifies host exit) sees an ABNORMAL exit and RELAUNCHES the instance —
1349    // the "reopens on its own" symptom (Discussion #1680). exit(0) gives the
1350    // launcher a clean code-0 shutdown; it reaps the host's children via its Job
1351    // Object (KILL_ON_JOB_CLOSE).
1352    // Unhook the Win32 event hooks before exit (no-op off Windows; a cheap,
1353    // safe UnhookWinEvent — NOT a crash site).
1354    wrr::uninstall_hooks();
1355    #[cfg(target_os = "macos")]
1356    {
1357        // macOS keeps the prior sequence (works): cef::shutdown() then a
1358        // non-blocking runtime teardown (#1268).
1359        shutdown();
1360        runtime.shutdown_background();
1361    }
1362    #[cfg(not(target_os = "macos"))]
1363    {
1364        // Windows/Linux: SKIP cef::shutdown() — the Windows crash site on the
1365        // still-alive recycled browsers. Drop the tokio runtime (safe +
1366        // non-blocking here) before the hard exit.
1367        drop(runtime);
1368    }
1369    tracing::info!("AgentMux host shutdown complete (fast exit)");
1370
1371    // On Windows, hard-terminate so the C-runtime / CEF-DLL static teardown
1372    // (atexit handlers + DLL_PROCESS_DETACH) does NOT run: with the recycled
1373    // browsers still alive, that teardown raises a fail-fast (exit 0xC0000602)
1374    // even though we reached this line, which the launcher classifies as an
1375    // ABNORMAL exit and RELAUNCHES ("reopens on its own"). `std::process::exit`
1376    // still runs that cleanup, so use `TerminateProcess(self, 0)` for an
1377    // immediate, clean code-0 termination; the launcher then shuts down and
1378    // reaps the host's children via its Job Object. See Discussion #1680.
1379    #[cfg(target_os = "windows")]
1380    unsafe {
1381        use windows_sys::Win32::System::Threading::{GetCurrentProcess, TerminateProcess};
1382        TerminateProcess(GetCurrentProcess(), 0);
1383    }
1384    std::process::exit(0)
1385}
1386
1387/// Windows DLL wrapper sandbox entry point (Phase 3, issue #1374).
1388///
1389/// `bootstrap.exe` (renamed `agentmux-cef.exe`) calls this after:
1390///   1. `cef_sandbox_info_create()` → `sandbox_info`
1391///   2. `LoadLibraryW("agentmux-cef.dll")` → this DLL
1392///   3. `GetProcAddress(hDll, "RunWinMain")` → this function
1393///
1394/// We forward `sandbox_info` into `run()` so CEF's `CefExecuteProcess` and
1395/// `CefInitialize` receive the pre-initialized sandbox context.
1396#[cfg(all(target_os = "windows", feature = "sandbox"))]
1397#[no_mangle]
1398pub unsafe extern "system" fn RunWinMain(
1399    _h_instance:   windows_sys::Win32::Foundation::HINSTANCE,
1400    _lp_cmd_line:  *mut u16,
1401    _n_cmd_show:   i32,
1402    sandbox_info:  *mut std::ffi::c_void,
1403    _version_info: *mut std::ffi::c_void,
1404) -> i32 {
1405    run(sandbox_info)
1406}
1407
1408/// Which directory the host publishes its `ipc-port-<hash>` file into.
1409///
1410/// This MUST agree with where the launcher reads it — `paths.data_dir`, used by
1411/// `second_instance::forward_host_cmd` (single-instance window forwarding) and
1412/// by `tray::windows::service_reachable` (the tray's running-indicator). When the
1413/// two diverge the failure is silent: forwarding reports a transient read error
1414/// and the tray icon simply claims the service is down forever.
1415///
1416/// The deciding factor is **whether a launcher spawned us**, not whether this
1417/// is a dev build. `AGENTMUX_IPC_HASH` is set by the launcher and by nothing
1418/// else (`supervisor/windows.rs`, `supervisor/unix.rs`), and whenever it is
1419/// set, `AGENTMUX_DATA_DIR` came from that same launcher via
1420/// `paths.common.to_env_vars()` — so it is that instance's own data dir, not
1421/// some other instance's.
1422///
1423/// Keying on `is_dev_build_exe` instead was wrong, on a premise stated in the
1424/// old comment: "in dev mode there is no launcher". `task dev` DOES run the
1425/// launcher — only `task dev:standalone` bypasses it. So a dev build under a
1426/// launcher wrote the port file to its host-local data dir while the launcher
1427/// looked in the instance data dir, and the tray indicator was stuck at "not
1428/// running" for the whole session. Verified live, not theorised: the file
1429/// landed in `<instance>/cef-cache/` while the tray probed `<instance>/data/`.
1430///
1431/// The parent-pane hazard the old branch guarded against is still guarded:
1432/// with no launcher there is no `AGENTMUX_IPC_HASH`, so a dev build falls
1433/// through to its own data dir and cannot clobber the port file of the parent
1434/// AgentMux instance whose env it inherited.
1435fn port_file_dir(
1436    launcher_managed: bool,
1437    env_data_dir: Option<std::path::PathBuf>,
1438    is_dev_build: bool,
1439    host_data_dir: &std::path::Path,
1440) -> std::path::PathBuf {
1441    match (launcher_managed, env_data_dir) {
1442        // A launcher spawned us: its data dir is authoritative, dev or not.
1443        (true, Some(dir)) => dir,
1444        // No launcher (or it told us nothing): a dev build must not touch the
1445        // inherited parent instance's port file.
1446        (_, inherited) => {
1447            if is_dev_build {
1448                host_data_dir.to_path_buf()
1449            } else {
1450                inherited.unwrap_or_else(|| host_data_dir.to_path_buf())
1451            }
1452        }
1453    }
1454}
1455
1456#[cfg(test)]
1457mod port_file_dir_tests {
1458    use super::port_file_dir;
1459    use std::path::{Path, PathBuf};
1460
1461    fn p(s: &str) -> PathBuf {
1462        PathBuf::from(s)
1463    }
1464
1465    /// The bug this function exists to fix. Under `task dev` a launcher IS
1466    /// present, so the port file must go where the launcher reads it —
1467    /// otherwise the tray's running-indicator is stuck false forever.
1468    #[test]
1469    fn a_dev_build_under_a_launcher_writes_where_the_launcher_reads() {
1470        assert_eq!(
1471            port_file_dir(
1472                true,
1473                Some(p("/instance/data")),
1474                true,
1475                Path::new("/instance/cef-cache")
1476            ),
1477            p("/instance/data")
1478        );
1479    }
1480
1481    /// The hazard the old dev branch was protecting against, kept intact:
1482    /// `dev:standalone` inherits the PARENT instance's `AGENTMUX_DATA_DIR`, and
1483    /// writing there would break that parent's single-instance forwarding.
1484    #[test]
1485    fn a_standalone_dev_build_does_not_clobber_the_parent_instances_port_file() {
1486        assert_eq!(
1487            port_file_dir(false, Some(p("/parent/data")), true, Path::new("/dev/cef-cache")),
1488            p("/dev/cef-cache")
1489        );
1490    }
1491
1492    #[test]
1493    fn a_release_build_uses_the_launcher_supplied_data_dir() {
1494        assert_eq!(
1495            port_file_dir(true, Some(p("/prod/data")), false, Path::new("/prod/cef-cache")),
1496            p("/prod/data")
1497        );
1498    }
1499
1500    /// An absent/empty `AGENTMUX_IPC_HASH` means no launcher, so a release
1501    /// build still honours an inherited data dir rather than losing it.
1502    #[test]
1503    fn a_release_build_without_a_launcher_still_honours_the_env_data_dir() {
1504        assert_eq!(
1505            port_file_dir(false, Some(p("/prod/data")), false, Path::new("/prod/cef-cache")),
1506            p("/prod/data")
1507        );
1508    }
1509
1510    #[test]
1511    fn nothing_configured_falls_back_to_the_host_data_dir() {
1512        assert_eq!(port_file_dir(true, None, false, Path::new("/fallback")), p("/fallback"));
1513        assert_eq!(port_file_dir(false, None, true, Path::new("/fallback")), p("/fallback"));
1514    }
1515}