Skip to main content

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}