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}