agentmux_srv/backend/remote/
wsl.rs1#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct WslLaunch {
12 pub args: Vec<String>,
14}
15
16pub 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
31pub 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 args.push("--exec".to_string());
48 if cmd_args.is_empty() {
49 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
59pub fn wslenv(existing: &str) -> String {
62 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
82pub 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 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#[cfg(any(windows, test))]
130pub fn parse_list(stdout: &[u8]) -> Vec<String> {
131 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 .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
164const LIST_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
166
167pub 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
186pub 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 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 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 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 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}