Skip to main content

agentmux_srv/backend/remote/
wsl.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! WSL terminals (P1 of SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
5//!
6//! A WSL pane is `wsl.exe -d <distro>` in a ConPTY: no network, no helper, no
7//! durability (the distro's shell lives as long as its VM, spec §3.3 and §5.5).
8
9/// What `wsl.exe` needs to start a pane's process in a distro.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct WslLaunch {
12    /// Arguments for `wsl.exe`.
13    pub args: Vec<String>,
14}
15
16/// Environment variables a WSL shell should see from the Windows side. WSL does
17/// not pass the Windows environment through; `WSLENV` lists what crosses. Never
18/// the local URL or auth key: those name this machine's srv, which a shell in
19/// the distro has no business calling without the user knowing.
20pub const FORWARDED_ENV: &[&str] = &[
21    "TERM",
22    "COLORTERM",
23    "TERM_PROGRAM",
24    "AGENTMUX_BLOCKID",
25    "AGENTMUX_TABID",
26    "AGENTMUX_VERSION",
27    "AGENTMUX_BUILD",
28    "AGENTMUX_INSTANCE_CHANNEL",
29];
30
31/// The `wsl.exe` arguments for a pane: an interactive login shell, or the
32/// pane's command run by the distro's `sh`. `cwd` is a path inside the distro;
33/// empty means the user's home.
34pub fn launch(distro: &str, cmd: &str, cmd_args: &[String], cwd: &str) -> WslLaunch {
35    let mut args = vec!["-d".to_string(), distro.to_string(), "--cd".to_string()];
36    args.push(if cwd.trim().is_empty() {
37        "~".to_string()
38    } else {
39        cwd.to_string()
40    });
41    if !cmd.is_empty() {
42        // `--exec`, not `--`: after `--` wsl.exe hands the rest to the user's
43        // login shell, which expands `$VAR`, `$(...)` and backticks once more
44        // before `sh -c` sees them (`echo '$HOME'` printed the home dir), and a
45        // non-POSIX login shell (fish) misreads the quoting. `--exec` runs the
46        // argv as given.
47        args.push("--exec".to_string());
48        if cmd_args.is_empty() {
49            // A command line, as a local pane runs it through `sh -c`.
50            args.extend(["sh".to_string(), "-c".to_string(), cmd.to_string()]);
51        } else {
52            args.push(cmd.to_string());
53            args.extend(cmd_args.iter().cloned());
54        }
55    }
56    WslLaunch { args }
57}
58
59/// `WSLENV` with [`FORWARDED_ENV`] added to whatever the user already set,
60/// without duplicates.
61pub fn wslenv(existing: &str) -> String {
62    // An `AGENTMUX_*` entry the user's own WSLENV already lists is dropped
63    // unless it is one of ours: the pane sets AGENTMUX_AUTH_KEY and
64    // AGENTMUX_LOCAL_URL on wsl.exe, and a listed name would carry them in.
65    let mut parts: Vec<String> = existing
66        .split(':')
67        .filter(|p| !p.is_empty())
68        .filter(|p| {
69            let name = p.split('/').next().unwrap_or_default();
70            !name.starts_with("AGENTMUX_") || FORWARDED_ENV.contains(&name)
71        })
72        .map(str::to_string)
73        .collect();
74    for name in FORWARDED_ENV {
75        if !parts.iter().any(|p| p.split('/').next() == Some(*name)) {
76            parts.push((*name).to_string());
77        }
78    }
79    parts.join(":")
80}
81
82/// `WSLENV` for a process in a distro: the terminal variables, the names its
83/// caller set (an agent `Shell` call's `env`, or a pane's configured
84/// `cmd:env`), and `GH_CONFIG_DIR` translated to a distro path (`/p`), so an
85/// agent's plain-`gh` guard holds inside the distro too and `gh` there cannot
86/// act as the user. Never an `AGENTMUX_*` variable beyond the terminal ones
87/// and the agent id: the auth key, the local URL and the jekt key stay on
88/// Windows.
89pub fn wslenv_with<'a>(existing: &str, caller_keys: impl IntoIterator<Item = &'a str>) -> String {
90    let guard = crate::backend::gh_guard::GH_CONFIG_DIR;
91    // Whatever flag the user's own WSLENV gives GH_CONFIG_DIR (`/u` would keep
92    // it from crossing), the guard's own `/p` entry replaces it.
93    let mut env = wslenv(existing)
94        .split(':')
95        .filter(|p| p.split('/').next() != Some(guard))
96        .collect::<Vec<_>>()
97        .join(":");
98    let mut push = |entry: String| {
99        let name = entry.split('/').next().unwrap_or_default().to_string();
100        let listed = env
101            .split(':')
102            .any(|p| p.split('/').next() == Some(name.as_str()));
103        if !listed {
104            if !env.is_empty() {
105                env.push(':');
106            }
107            env.push_str(&entry);
108        }
109    };
110    for key in caller_keys {
111        let forwardable = !key.is_empty()
112            && !key.contains([':', '/', '='])
113            && (!key.starts_with("AGENTMUX_")
114                || FORWARDED_ENV.contains(&key)
115                || key == "AGENTMUX_AGENT_ID")
116            && key != guard
117            && key != "WSLENV";
118        if forwardable {
119            push(key.to_string());
120        }
121    }
122    push(format!("{guard}/p"));
123    env
124}
125
126/// Parse `wsl.exe --list --quiet`. It writes UTF-16LE (with or without a BOM),
127/// one distribution per line; with none installed it prints a sentence and
128/// exits non-zero, which the caller treats as "none".
129#[cfg(any(windows, test))]
130pub fn parse_list(stdout: &[u8]) -> Vec<String> {
131    // UTF-16LE when it starts with its BOM, or when every second byte is zero
132    // (ASCII names in UTF-16LE); otherwise UTF-8.
133    let body = stdout.strip_prefix(&[0xff, 0xfe]).unwrap_or(stdout);
134    let utf16 = body.len() != stdout.len()
135        || (stdout.len() >= 2
136            && stdout.len() % 2 == 0
137            && stdout.iter().skip(1).step_by(2).all(|b| *b == 0));
138    let text = if utf16 && body.len() % 2 == 0 {
139        let units: Vec<u16> = body
140            .chunks_exact(2)
141            .map(|c| u16::from_le_bytes([c[0], c[1]]))
142            .collect();
143        String::from_utf16_lossy(&units)
144    } else {
145        String::from_utf8_lossy(stdout).into_owned()
146    };
147    text.trim_start_matches('\u{feff}')
148        .lines()
149        .map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\0'))
150        .filter(|l| !l.is_empty())
151        // Docker Desktop's own utility VMs: a root shell with no home and
152        // nothing a user works in. Listing them only invites a confusing pane.
153        .filter(|l| {
154            !l.eq_ignore_ascii_case("docker-desktop")
155                && !l.eq_ignore_ascii_case("docker-desktop-data")
156        })
157        .filter(|l| {
158            super::conn::ConnTarget::parse(&format!("{}{l}", super::conn::WSL_PREFIX)).is_ok()
159        })
160        .map(str::to_string)
161        .collect()
162}
163
164/// How long [`list_cached`] keeps an answer.
165const LIST_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
166
167/// [`list`], kept for [`LIST_CACHE_TTL`]: for the places Hangar and the editor
168/// tree show each time they open, so a slow `wsl.exe` costs at most one wait
169/// per half minute. A connection check uses [`list`], so a distro installed a
170/// moment ago is found there.
171pub async fn list_cached() -> Vec<String> {
172    use std::sync::{Mutex, OnceLock};
173    use std::time::Instant;
174    static CACHE: OnceLock<Mutex<Option<(Instant, Vec<String>)>>> = OnceLock::new();
175    let cache = CACHE.get_or_init(|| Mutex::new(None));
176    if let Some((at, distros)) = cache.lock().unwrap().as_ref() {
177        if at.elapsed() < LIST_CACHE_TTL {
178            return distros.clone();
179        }
180    }
181    let distros = list().await;
182    *cache.lock().unwrap() = Some((Instant::now(), distros.clone()));
183    distros
184}
185
186/// The installed distributions, or none (not Windows, no WSL, or no distro).
187pub async fn list() -> Vec<String> {
188    #[cfg(windows)]
189    {
190        use agentmux_common::win32::NoWindow;
191        let mut cmd = tokio::process::Command::new("wsl.exe");
192        cmd.args(["--list", "--quiet"]).no_window();
193        match tokio::time::timeout(std::time::Duration::from_secs(5), cmd.output()).await {
194            Ok(Ok(out)) if out.status.success() => parse_list(&out.stdout),
195            _ => Vec::new(),
196        }
197    }
198    #[cfg(not(windows))]
199    {
200        Vec::new()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn utf16(s: &str, bom: bool) -> Vec<u8> {
209        let mut out = Vec::new();
210        if bom {
211            out.extend_from_slice(&0xfeffu16.to_le_bytes());
212        }
213        for u in s.encode_utf16() {
214            out.extend_from_slice(&u.to_le_bytes());
215        }
216        out
217    }
218
219    #[test]
220    fn the_list_is_read_in_utf16_with_or_without_a_bom() {
221        for bom in [false, true] {
222            let out = utf16("Ubuntu\r\ndocker-desktop\r\n\r\nUbuntu-24.04\r\n", bom);
223            assert_eq!(
224                parse_list(&out),
225                ["Ubuntu", "Ubuntu-24.04"],
226                "docker-desktop is Docker's utility VM, not the user's"
227            );
228        }
229        // Some builds print UTF-8.
230        assert_eq!(
231            parse_list(b"Debian\nkali-linux\n"),
232            ["Debian", "kali-linux"]
233        );
234        assert!(parse_list(b"").is_empty());
235    }
236
237    #[test]
238    fn a_line_that_is_not_a_distro_name_is_dropped() {
239        // The "no installed distributions" sentence, if it ever arrives on stdout.
240        let out = utf16(
241            "Windows Subsystem for Linux has no installed distributions.\r\n",
242            false,
243        );
244        assert!(parse_list(&out).is_empty());
245    }
246
247    #[test]
248    fn an_interactive_pane_is_a_login_shell_at_home_or_its_cwd() {
249        assert_eq!(
250            launch("Ubuntu", "", &[], "").args,
251            ["-d", "Ubuntu", "--cd", "~"]
252        );
253        assert_eq!(
254            launch("Ubuntu", "", &[], "/srv/app").args,
255            ["-d", "Ubuntu", "--cd", "/srv/app"]
256        );
257    }
258
259    #[test]
260    /// Run with `--exec`, so only `sh -c` parses the command line, not the
261    /// user's login shell first (#4253).
262    fn a_command_runs_in_the_distro_not_on_windows() {
263        assert_eq!(
264            launch("Ubuntu", "echo '$HOME' && echo ok", &[], "").args,
265            [
266                "-d",
267                "Ubuntu",
268                "--cd",
269                "~",
270                "--exec",
271                "sh",
272                "-c",
273                "echo '$HOME' && echo ok"
274            ]
275        );
276        assert_eq!(
277            launch("Ubuntu", "htop", &["-d".to_string(), "10".to_string()], "").args,
278            ["-d", "Ubuntu", "--cd", "~", "--exec", "htop", "-d", "10"]
279        );
280    }
281
282    #[test]
283    fn wslenv_adds_the_terminal_variables_once_and_keeps_the_users() {
284        let env = wslenv("");
285        assert_eq!(env, FORWARDED_ENV.join(":"));
286        // The user's own WSLENV cannot carry the instance's credentials in.
287        let env = wslenv("AGENTMUX_AUTH_KEY:AGENTMUX_LOCAL_URL/u:PATH/l:AGENTMUX_TABID");
288        assert!(
289            !env.contains("AGENTMUX_AUTH_KEY") && !env.contains("AGENTMUX_LOCAL_URL"),
290            "{env}"
291        );
292        assert!(env.starts_with("PATH/l:AGENTMUX_TABID:"), "{env}");
293        assert!(!wslenv_with("AGENTMUX_AUTH_KEY/p", []).contains("AGENTMUX_AUTH_KEY"));
294        let env = wslenv("USERPROFILE/p:TERM/u");
295        assert!(env.starts_with("USERPROFILE/p:TERM/u:"), "{env}");
296        let names: Vec<&str> = env
297            .split(':')
298            .map(|p| p.split('/').next().unwrap())
299            .collect();
300        assert_eq!(
301            names.iter().filter(|n| **n == "TERM").count(),
302            1,
303            "TERM once: {env}"
304        );
305        assert!(!env.contains("AGENTMUX_AUTH_KEY") && !env.contains("AGENTMUX_LOCAL_URL"));
306    }
307
308    #[test]
309    fn an_agent_command_carries_its_own_env_and_the_gh_guard_but_no_agentmux_secret() {
310        let env = wslenv_with(
311            "GH_CONFIG_DIR/u:USERPROFILE/p",
312            [
313                "RUST_LOG",
314                "AGENTMUX_AUTH_KEY",
315                "AGENTMUX_LOCAL_URL",
316                "AGENTMUX_JEKT_KEY",
317                "AGENTMUX_BLOCKID",
318                "AGENTMUX_AGENT_ID",
319                "GH_CONFIG_DIR",
320                "WSLENV",
321                "BAD:NAME",
322                "",
323            ],
324        );
325        let entries: Vec<&str> = env.split(':').collect();
326        assert!(entries.contains(&"RUST_LOG"), "{env}");
327        assert!(
328            entries.contains(&"AGENTMUX_AGENT_ID"),
329            "a pane's configured identity crosses: {env}"
330        );
331        assert!(
332            entries.contains(&"USERPROFILE/p"),
333            "the user's own entries stay: {env}"
334        );
335        assert_eq!(
336            entries
337                .iter()
338                .filter(|e| e.starts_with("GH_CONFIG_DIR"))
339                .collect::<Vec<_>>(),
340            [&"GH_CONFIG_DIR/p"],
341            "the guard crosses as a distro path, whatever the user's flag was: {env}"
342        );
343        assert_eq!(
344            entries
345                .iter()
346                .filter(|e| e.starts_with("AGENTMUX_BLOCKID"))
347                .count(),
348            1
349        );
350        for secret in [
351            "AGENTMUX_AUTH_KEY",
352            "AGENTMUX_LOCAL_URL",
353            "AGENTMUX_JEKT_KEY",
354            "WSLENV",
355            "BAD",
356        ] {
357            assert!(
358                !entries.iter().any(|e| e.starts_with(secret)),
359                "{secret} in {env}"
360            );
361        }
362    }
363}