agentmux_srv/util.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Small OS-level helpers shared across backend modules.
5
6#[cfg(windows)]
7use agentmux_common::win32::NoWindow;
8
9/// Open `url` in the user's default browser.
10///
11/// On Windows this shells out via `cmd /C start`. `cmd.exe` re-parses its
12/// trailing argument as a batch command line, where `&`, `|`, `^`, `<`, `>`
13/// are operators outside of quotes — an OAuth authorize URL is full of
14/// unencoded `&` between query params, so passing it bare truncates the URL
15/// at the first `&` and silently drops every parameter after it (client_id,
16/// redirect_uri, scope, ...). Wrapping the URL in its own quoted argument via
17/// `raw_arg` keeps `cmd.exe` in quoted mode for the whole string, so those
18/// characters are never seen as operators. `raw_arg` (not `arg`) is required
19/// here: `Command::arg` would apply MSVCRT-style quote escaping, which
20/// `cmd.exe`'s simpler quote-toggle parser does not understand and would
21/// re-break out of the quoted region.
22/// The spawned browser is a long-lived third-party process, so it gets the
23/// strict policy (`sanitize_external_std_command`), not the pane keep-set:
24/// `xdg-open` chains to whatever the desktop has registered, which can be a
25/// file manager or terminal from which another AgentMux build is launched —
26/// invariant I7, and the mechanism behind
27/// `docs/retro/retro-env-inheritance-instance-isolation-breach-2026-09-17.md`
28/// §2.2. On Linux the inherited values are also readable by any same-uid
29/// process via `/proc/PID/environ` for as long as the browser lives.
30pub fn open_browser(url: &str) {
31 #[cfg(target_os = "windows")]
32 {
33 use std::os::windows::process::CommandExt;
34 let mut cmd = std::process::Command::new("cmd");
35 crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
36 cmd.arg("/C").raw_arg(format!("start \"\" \"{url}\""));
37 // CREATE_NO_WINDOW: console-flash suppression, see crates/common/src/cli.rs
38 cmd.no_window();
39 let _ = cmd.spawn();
40 }
41 #[cfg(target_os = "macos")]
42 {
43 let mut cmd = std::process::Command::new("open");
44 crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
45 let _ = cmd.arg(url).spawn();
46 }
47 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
48 {
49 let mut cmd = std::process::Command::new("xdg-open");
50 crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
51 let _ = cmd.arg(url).spawn();
52 }
53}
54
55#[cfg(all(test, target_os = "windows"))]
56mod tests {
57 use std::os::windows::process::CommandExt;
58
59 // Regresses the truncation bug this module fixes: cmd.exe splits an
60 // unquoted `&` into a new command, so an OAuth URL's query params after
61 // the first `&` never reached the browser. Runs the same quoted `raw_arg`
62 // construction as `open_browser`, swapping `start` for `echo` so the
63 // output is inspectable, and asserts the URL survives intact.
64 #[test]
65 fn quoted_url_survives_cmd_ampersand_splitting() {
66 let url = "https://example.com/oauth2/authorize?response_type=code&client_id=abc&redirect_uri=http://127.0.0.1:9379/callback&scope=openid";
67
68 let output = std::process::Command::new("cmd")
69 .arg("/C")
70 .raw_arg(format!("echo \"{url}\""))
71 .output()
72 .expect("failed to run cmd.exe");
73
74 let stdout = String::from_utf8_lossy(&output.stdout);
75 assert!(
76 stdout.contains(url),
77 "URL was truncated by cmd.exe's `&` splitting: {stdout}"
78 );
79 }
80
81 // Sanity check that the bug is real: the old unquoted invocation truncates
82 // at the first `&`.
83 #[test]
84 fn unquoted_url_is_truncated_by_cmd_ampersand_splitting() {
85 let url = "https://example.com/oauth2/authorize?response_type=code&client_id=abc";
86
87 let output = std::process::Command::new("cmd")
88 .args(["/C", "echo", url])
89 .output()
90 .expect("failed to run cmd.exe");
91
92 let stdout = String::from_utf8_lossy(&output.stdout);
93 assert!(
94 !stdout.contains(url),
95 "expected truncation, got intact URL: {stdout}"
96 );
97 assert!(stdout.contains("response_type=code"));
98 }
99}