Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:54:42.242Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

windows_wsl.rs

1976 lines · 75.5 KB · rust
1//! Windows sandbox integration via WSL.
2//!
3//! Sandboxed Windows terminal commands are routed through WSL and then executed
4//! under Bubblewrap inside Linux. Projects may be opened either from native
5//! Windows paths (`C:\Users\...`) or WSL UNC paths
6//! (`\\wsl.localhost\Ubuntu\home\...`). Native drive-letter paths are
7//! translated into the distro's filesystem view with `wslpath` (falling back
8//! to the conventional `/mnt/<drive>/...` mapping if that fails) and use the
9//! user's default WSL distro unless a WSL UNC path in the request pins a
10//! specific distro.
11//!
12//! Errors fall into two classes the agent treats differently:
13//!
14//! * **Environment unavailable** — WSL missing or failing to start, no
15//!   usable `bwrap`, or the probe/path-resolution stdout protocol breaking
16//!   down. These are returned as a [`WslSandboxUnavailable`] (whose `Display`
17//!   carries
18//!   [`WSL_SANDBOX_UNAVAILABLE_PREFIX`](crate::WSL_SANDBOX_UNAVAILABLE_PREFIX)),
19//!   so the agent recognizes them *by type* and offers the same
20//!   retry / run-unsandboxed fallback it offers on Linux, rather than matching
21//!   on message text.
22//! * **Bad request** — a specific path that doesn't exist or can't be mapped
23//!   into WSL, or a request mixing distros. These are ordinary `anyhow` errors
24//!   *without* [`WslSandboxUnavailable`], and are reported back to the model,
25//!   which can fix the request and retry.
26
27use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29use std::sync::{Mutex, OnceLock};
30
31use smol::process::{Command, Stdio};
32
33use anyhow::{Context as _, Result, bail, ensure};
34
35use crate::WSL_SANDBOX_UNAVAILABLE_PREFIX;
36
37/// Per-command relaxations of the WSL/Bubblewrap sandbox. Windows can only
38/// toggle network access wholesale (no loopback-proxy confinement yet), so this
39/// is a plain bool rather than the richer cross-platform [`crate::SandboxNetPolicy`].
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
41pub(crate) struct SandboxPermissions {
42    pub(crate) allow_network: bool,
43    pub(crate) allow_fs_write: bool,
44}
45
46/// Exit code the environment probe script uses to signal that `bwrap` is not
47/// installed, distinguishing that from WSL itself failing to start a shell.
48/// Chosen to be unlikely to collide with `wsl.exe`'s own failure codes.
49const BWRAP_MISSING_EXIT_CODE: i32 = 41;
50
51/// Exit code the environment probe script uses to signal that `bwrap` is
52/// installed but failed the sandbox smoke test — typically because the
53/// distro restricts unprivileged user namespaces (e.g. Ubuntu 24.04's
54/// default AppArmor policy), which every namespace flag we pass depends on.
55const BWRAP_UNUSABLE_EXIT_CODE: i32 = 42;
56
57/// Prefix of the probe script's single result line, so it can be picked out
58/// of any stdout noise printed by the login shell's profile scripts.
59const PROBE_RESULT_PREFIX: &str = "zed-wsl-probe:";
60
61/// Prefix of the helper-provisioning script's single result line (the absolute
62/// in-WSL path of the Linux `zed` to run as the sandbox helper), picked out of
63/// login-shell stdout noise just like [`PROBE_RESULT_PREFIX`].
64const HELPER_RESULT_PREFIX: &str = "zed-wsl-helper:";
65
66/// Ensures a Linux `zed` matching the running release is available inside WSL to
67/// act as the sandbox helper (`--wsl-sandbox-helper`), and prints its absolute
68/// in-WSL path on a [`HELPER_RESULT_PREFIX`] line. `$1` is the release channel,
69/// `$2` the version (`latest` for dev builds, which have no matching release and
70/// so track the latest nightly); both are passed as argv, never interpolated, so
71/// a version/channel string can't inject shell.
72///
73/// Unlike a normal Linux install, this deliberately does **not** consult the WSL
74/// `PATH`: inside WSL `zed` typically resolves to the *Windows* `zed.exe` via
75/// interop, which is not a Linux binary and so can't be the helper. It also does
76/// not use the public install script (`install.sh`), which puts `zed` on the
77/// user's `PATH` and writes desktop entries we don't want. Instead the Windows
78/// side resolves the exact channel+version (see `wsl_zed_release`) and this
79/// script downloads that release's Linux tarball straight from
80/// `cloud.zed.dev/releases` and unpacks it into a private, off-`PATH` location
81/// (`~/.local/libexec/zed/<channel>`, the conventional spot for executables run
82/// by other programs rather than directly by the user). One managed copy per
83/// channel is kept, tracked by a marker file so an exact channel+version match
84/// is reused rather than re-downloaded. The floating `latest` version (dev
85/// builds) is the exception: it always re-downloads so it tracks the newest
86/// nightly rather than pinning to the first copy fetched.
87///
88/// We ship no `zed` (nor `bwrap`) into WSL ourselves; this downloads `zed` on
89/// demand. A missing `curl`/`wget` (or a failed download) is a hard error the
90/// caller surfaces to the user, exactly like a missing `bwrap`.
91const HELPER_PROVISION_SCRIPT: &str = r#"
92set -eu
93channel="$1"
94version="$2"
95dest="$HOME/.local/libexec/zed/$channel"
96marker="$dest/.zed-wsl-helper-version"
97want="$channel $version"
98
99# Reuse an exact, already-installed channel+version — but never for the floating
100# "latest" tag (dev builds), which must always re-fetch so they track the most
101# recent nightly instead of pinning to whatever was downloaded first.
102if [ "$version" != "latest" ] && [ "$(cat "$marker" 2>/dev/null || true)" = "$want" ]; then
103    helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
104    if [ -n "$helper" ] && [ -x "$helper" ]; then
105        printf 'zed-wsl-helper: %s\n' "$helper"
106        exit 0
107    fi
108fi
109
110arch=$(uname -m)
111case "$arch" in
112    x86_64 | amd64) arch="x86_64" ;;
113    aarch64 | arm64) arch="aarch64" ;;
114    *) echo "unsupported WSL architecture for the zed sandbox helper: $arch" >&2; exit 1 ;;
115esac
116url="https://cloud.zed.dev/releases/$channel/$version/download?asset=zed&arch=$arch&os=linux&source=zed-wsl-sandbox"
117
118tmp=$(mktemp -d "${TMPDIR:-/tmp}/zed-wsl-helper-XXXXXX")
119trap 'rm -rf "$tmp"' EXIT
120tarball="$tmp/zed.tar.gz"
121if command -v curl >/dev/null 2>&1; then
122    curl -fL "$url" -o "$tarball"
123elif command -v wget >/dev/null 2>&1; then
124    wget -O "$tarball" "$url"
125else
126    echo 'neither curl nor wget is available in WSL to download zed' >&2
127    exit 1
128fi
129
130mkdir -p "$tmp/unpacked"
131tar -xzf "$tarball" -C "$tmp/unpacked"
132helper_src=$(find "$tmp/unpacked" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
133if [ -z "$helper_src" ]; then
134    echo 'the downloaded zed tarball did not contain a libexec/zed-editor binary' >&2
135    exit 1
136fi
137app=$(dirname "$(dirname "$helper_src")")
138
139# Install atomically: stage the unpacked app next to the destination on the same
140# filesystem, then swap it into place so a concurrent run never sees (or execs) a
141# partially-written install. The whole app dir is kept so the binary's bundled
142# libraries ($ORIGIN/../lib) remain alongside it.
143mkdir -p "$(dirname "$dest")"
144rm -rf "$dest.new" "$dest.old"
145cp -a "$app" "$dest.new"
146if [ -e "$dest" ]; then mv "$dest" "$dest.old"; fi
147mv "$dest.new" "$dest"
148rm -rf "$dest.old"
149printf '%s' "$want" > "$marker"
150
151helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
152if [ -z "$helper" ] || [ ! -x "$helper" ]; then
153    echo "the installed zed sandbox helper is missing or not executable under $dest" >&2
154    exit 1
155fi
156printf 'zed-wsl-helper: %s\n' "$helper"
157exit 0
158"#;
159
160/// Marks a failure of the Windows WSL sandboxing *environment*: WSL is missing
161/// or won't start, there's no usable `bwrap`, or the probe / path-resolution
162/// stdout protocol broke down. Returned as the root of the `anyhow::Error` so
163/// callers classify it by type ([`anyhow::Error::downcast_ref`]) instead of by
164/// matching message text. Per-request failures (a missing writable path, paths
165/// mixing distros) are ordinary `anyhow` errors *without* this type, so they
166/// never match — the agent returns those to the model rather than offering to
167/// run unsandboxed.
168#[derive(Debug, Clone)]
169pub struct WslSandboxUnavailable(String);
170
171impl WslSandboxUnavailable {
172    /// Build an environment-unavailable error from a human-readable reason
173    /// (without the [`WSL_SANDBOX_UNAVAILABLE_PREFIX`], which `Display` adds).
174    pub fn new(message: impl Into<String>) -> Self {
175        Self(message.into())
176    }
177
178    /// The reason, without the leading [`WSL_SANDBOX_UNAVAILABLE_PREFIX`].
179    #[cfg(test)]
180    pub fn message(&self) -> &str {
181        &self.0
182    }
183}
184
185impl std::fmt::Display for WslSandboxUnavailable {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        write!(f, "{WSL_SANDBOX_UNAVAILABLE_PREFIX}: {}", self.0)
188    }
189}
190
191impl std::error::Error for WslSandboxUnavailable {}
192
193/// Shorthand for an [`anyhow::Error`] wrapping a [`WslSandboxUnavailable`].
194fn unavailable(message: impl Into<String>) -> anyhow::Error {
195    anyhow::Error::new(WslSandboxUnavailable::new(message))
196}
197
198#[derive(Clone, Debug, Eq, Hash, PartialEq)]
199struct WslPath {
200    distro: Option<String>,
201    path: String,
202}
203
204/// A path mapped for use inside WSL.
205///
206/// WSL UNC and WSL-absolute paths can be mapped structurally up front. Native
207/// drive-letter paths depend on the distro's automount configuration
208/// (`/etc/wsl.conf` can move the `/mnt` root), so they are translated with
209/// `wslpath` inside the distro — but a distro can only be chosen after every
210/// path has been parsed (WSL UNC paths pin one), hence this two-stage shape:
211/// parse structurally first, then resolve native paths via [`resolve_paths`]
212/// once the distro is known.
213#[derive(Clone, Debug, Eq, Hash, PartialEq)]
214enum PathMapping {
215    Wsl(WslPath),
216    NativeDrive {
217        /// The `\\?\`-stripped, forward-slashed form that `wslpath -u`
218        /// accepts (`wslpath` is a Linux binary and doesn't understand
219        /// backslash separators).
220        windows_path: String,
221        /// The conventional `/mnt/<drive>/...` mapping, used when `wslpath`
222        /// translation fails.
223        fallback: WslPath,
224    },
225}
226
227impl PathMapping {
228    fn distro(&self) -> Option<&str> {
229        match self {
230            PathMapping::Wsl(path) => path.distro.as_deref(),
231            PathMapping::NativeDrive { .. } => None,
232        }
233    }
234}
235
236/// Wrap a Linux process invocation so it runs under Bubblewrap inside WSL.
237///
238/// `program` and `args` must name a Linux executable and Linux argv, not a
239/// Windows executable. The caller is expected to convert the model's command
240/// into a Linux shell invocation (typically `/bin/sh -c ...`) before calling
241/// this function.
242///
243/// All writable paths, protected paths, and the cwd must be paths that can be
244/// mapped into WSL. The cwd and writable paths must exist; protected paths may
245/// be missing because Bubblewrap cannot bind a missing source. WSL UNC paths
246/// may specify a distro; native drive-letter paths are translated with `wslpath`
247/// inside either that distro or the default distro (falling back to
248/// `/mnt/<drive>/...` if translation fails).
249///
250/// `env` is forwarded into the sandboxed command via `bwrap --setenv` rather
251/// than being set on the `wsl.exe` process. Windows environment variables
252/// don't cross the WSL boundary unless they're listed in `WSLENV`, so without
253/// this the command would lose `PAGER` (used to stop `git` from paging into
254/// the PTY) and the rest of the project environment. Variables whose Windows
255/// values are meaningless or harmful inside Linux are dropped (see
256/// [`is_forwardable_env_var`]).
257///
258/// This function performs up to two `wsl.exe` round-trips (environment probe
259/// and path resolution, each cached) plus filesystem stats of WSL UNC paths,
260/// any of which can take seconds when the WSL VM is cold (and the stats can
261/// stall on a slow `\\wsl.localhost` filesystem). Run it on a background
262/// executor, never on the UI thread, and bound it with a timeout — a wedged
263/// `wsl.exe` (a real failure mode when the WSL service is unhealthy)
264/// otherwise stalls the returned future forever. This crate deliberately has
265/// no timer of its own (timers come from the caller's executor so tests stay
266/// deterministic); instead it guarantees that dropping the future kills any
267/// in-flight `wsl.exe` child, so a caller-side timeout that drops the future
268/// also reaps the process. Parameters are owned so the returned future is
269/// `Send + 'static`.
270pub async fn wrap_invocation<S: std::hash::BuildHasher>(
271    program: String,
272    args: Vec<String>,
273    writable_paths: Vec<PathBuf>,
274    protected_paths: Vec<PathBuf>,
275    permissions: SandboxPermissions,
276    cwd: Option<PathBuf>,
277    env: HashMap<String, String, S>,
278    // `(release channel, version)` of the Linux `zed` to provision inside WSL as
279    // the `--wsl-sandbox-helper` (the version is `latest` for dev builds). When
280    // `None`, no helper is used and bwrap is exec'd directly — the legacy path,
281    // which binds writable paths by string and so carries the bind-source TOCTOU
282    // the helper closes. Callers that can determine the running release should
283    // always pass `Some`.
284    wsl_zed_release: Option<(String, String)>,
285) -> Result<(String, Vec<String>)> {
286    // Mapping failures are bad requests (a path that doesn't exist or has a
287    // shape WSL can't address), not environment problems, so no
288    // `WSL_SANDBOX_UNAVAILABLE_PREFIX` here.
289    let cwd_mapping =
290        match &cwd {
291            Some(cwd) => Some(directory_to_wsl(cwd).with_context(|| {
292                format!("failed to map terminal cwd `{}` into WSL", cwd.display())
293            })?),
294            None => None,
295        };
296
297    let writable_mappings = writable_paths
298        .iter()
299        .map(|path| {
300            path_to_wsl(path).with_context(|| {
301                format!("failed to map writable path `{}` into WSL", path.display())
302            })
303        })
304        .collect::<Result<Vec<_>>>()?;
305    let protected_mappings = protected_paths
306        .iter()
307        .map(|path| {
308            path_to_wsl_allowing_missing(path).with_context(|| {
309                format!("failed to map protected path `{}` into WSL", path.display())
310            })
311        })
312        .collect::<Result<Vec<_>>>()?;
313
314    let distro = select_distro(
315        cwd_mapping.as_ref(),
316        writable_mappings.iter().chain(protected_mappings.iter()),
317    )?;
318    let wsl_exe = wsl_exe_path();
319    if !wsl_exe.is_file() {
320        return Err(unavailable(format!(
321            "WSL (`wsl.exe`) was not found at `{}`",
322            wsl_exe.display()
323        )));
324    }
325    let environment = probe_environment(&wsl_exe, distro.as_deref()).await?;
326
327    // Resolve all paths (translating native drive-letter paths with `wslpath`
328    // now that the distro is known) in a single WSL round-trip. The cwd and
329    // ordinary writable paths must exist; protected paths may be missing because,
330    // as with Linux bwrap, a not-yet-created path can't be overlaid.
331    let has_cwd = cwd_mapping.is_some();
332    let writable_path_count = writable_mappings.len();
333    let mut mappings = Vec::with_capacity(writable_mappings.len() + protected_mappings.len() + 1);
334    if let Some(mapping) = cwd_mapping {
335        mappings.push((mapping, "terminal cwd", true));
336    }
337    mappings.extend(
338        writable_mappings
339            .into_iter()
340            .map(|mapping| (mapping, "writable path", true)),
341    );
342    mappings.extend(
343        protected_mappings
344            .into_iter()
345            .map(|mapping| (mapping, "protected path", false)),
346    );
347    let resolved = resolve_paths(&wsl_exe, distro.as_deref(), &mappings).await?;
348    let (cwd, writable_paths, protected_paths) =
349        split_resolved_paths(has_cwd, writable_path_count, resolved)?;
350
351    let mut wsl_args = Vec::new();
352    if let Some(distro) = distro.as_deref() {
353        wsl_args.extend(["-d".to_string(), distro.to_string()]);
354    }
355    if let Some(cwd) = &cwd {
356        wsl_args.extend(["--cd".to_string(), cwd.clone()]);
357    }
358
359    // The bwrap *options* (everything before the trailing `-- cmd`): root bind,
360    // `/tmp` tmpfs, writable binds, interop blocking, `--setenv`s, `--chdir`,
361    // namespace flags. Identical whether or not we route through the helper.
362    let bwrap_options = build_bwrap_args(
363        &writable_paths,
364        &protected_paths,
365        permissions,
366        cwd.as_deref(),
367        environment.mask_interop_dir,
368        &env,
369    );
370
371    match wsl_zed_release {
372        // Preferred path: run the in-WSL `zed` as the sandbox helper, which
373        // captures the writable binds' inodes WSL-side and validates them after
374        // bwrap's mounts (the same in-sandbox check native Linux performs).
375        Some((channel, version)) => {
376            let helper =
377                ensure_wsl_zed_helper(&wsl_exe, distro.as_deref(), &channel, &version).await?;
378            wsl_args.extend(["--exec".to_string(), helper]);
379            // Protocol (decoded by `linux_bubblewrap::decode_wsl_helper_args`):
380            //   <flag> <bwrap_path> <n_base> <base...> <n_writable> <writable...> -- <prog> <args>
381            wsl_args.push(crate::WSL_SANDBOX_HELPER_FLAG.to_string());
382            wsl_args.push(environment.bwrap_path.clone());
383            wsl_args.push(bwrap_options.len().to_string());
384            wsl_args.extend(bwrap_options);
385            wsl_args.push(writable_paths.len().to_string());
386            wsl_args.extend(writable_paths.iter().cloned());
387            wsl_args.push("--".to_string());
388            wsl_args.push(program);
389            wsl_args.extend(args);
390        }
391        // Legacy path: exec bwrap directly (no in-sandbox bind validation). Use
392        // the absolute path the probe validated, since `wsl --exec` searches
393        // only the default WSL PATH.
394        None => {
395            wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
396            wsl_args.extend(bwrap_options);
397            wsl_args.push("--".to_string());
398            wsl_args.push(program);
399            wsl_args.extend(args);
400        }
401    }
402
403    Ok((wsl_exe.to_string_lossy().into_owned(), wsl_args))
404}
405
406fn split_resolved_paths(
407    has_cwd: bool,
408    writable_path_count: usize,
409    resolved: Vec<Option<String>>,
410) -> Result<(Option<String>, Vec<String>, Vec<String>)> {
411    let mut resolved = resolved.into_iter();
412    let cwd = if has_cwd {
413        Some(
414            resolved
415                .next()
416                .context("bug: missing resolved terminal cwd")?
417                .context("bug: required terminal cwd resolved as missing")?,
418        )
419    } else {
420        None
421    };
422
423    let mut writable_paths = Vec::with_capacity(writable_path_count);
424    for _ in 0..writable_path_count {
425        writable_paths.push(
426            resolved
427                .next()
428                .context("bug: missing resolved writable path")?
429                .context("bug: required writable path resolved as missing")?,
430        );
431    }
432
433    Ok((cwd, writable_paths, resolved.flatten().collect()))
434}
435
436fn select_distro<'a>(
437    cwd: Option<&PathMapping>,
438    paths: impl IntoIterator<Item = &'a PathMapping>,
439) -> Result<Option<String>> {
440    let mut distro = cwd.and_then(|mapping| mapping.distro().map(str::to_string));
441    for mapping in paths {
442        let Some(path_distro) = mapping.distro() else {
443            continue;
444        };
445        match distro.as_deref() {
446            // A bad request, not an environment problem: the model (or
447            // project layout) asked for paths spanning two distros, which a
448            // single bwrap invocation can't serve.
449            Some(distro) => ensure!(
450                distro == path_distro,
451                "cannot sandbox a command whose paths mix WSL distros `{}` and `{}`",
452                distro,
453                path_distro
454            ),
455            None => distro = Some(path_distro.to_string()),
456        }
457    }
458    Ok(distro)
459}
460
461/// What [`probe_environment`] learned about a WSL distro.
462#[derive(Clone, Debug, Eq, PartialEq)]
463struct EnvironmentProbe {
464    /// Whether the WSL interop socket directory (`/run/WSL`) exists and so
465    /// must (and can) be masked — see [`build_bwrap_args`].
466    mask_interop_dir: bool,
467    /// Absolute path of the `bwrap` binary the smoke test validated. The real
468    /// invocation must exec this exact path: `wsl --exec` searches only the
469    /// default WSL PATH, so a bare `bwrap` could miss (or differ from) the
470    /// binary the probe's login shell found.
471    bwrap_path: String,
472}
473
474/// Shell script run by [`probe_environment`]. Resolves `bwrap` to an absolute
475/// path (exit [`BWRAP_MISSING_EXIT_CODE`] if absent), rejects setuid-root
476/// binaries, then smoke-tests a real minimal sandbox (exit
477/// [`BWRAP_UNUSABLE_EXIT_CODE`] on failure) using the same mount and namespace
478/// flags as [`build_bwrap_args`] — presence isn't
479/// enough, because unprivileged user namespaces can be disabled by the
480/// distro's kernel, sysctl, or AppArmor policy (notably Ubuntu 24.04, the
481/// current default WSL distro), in which case `bwrap` exists but every
482/// sandboxed command would fail. The interop mask is included in the smoke
483/// test when `/run/WSL` exists so the exact mount we later perform is
484/// exercised too. On success, one [`PROBE_RESULT_PREFIX`]-marked result line
485/// reports the interop state and the resolved `bwrap` path.
486fn probe_script() -> String {
487    format!(
488        "bwrap_path=$(command -v bwrap) || exit {BWRAP_MISSING_EXIT_CODE}; \
489         if [ -u \"$bwrap_path\" ] && [ \"$(stat -c %u \"$bwrap_path\" 2>/dev/null)\" = 0 ]; then \
490         echo 'setuid-root bwrap is not supported' >&2; \
491         exit {BWRAP_UNUSABLE_EXIT_CODE}; fi; \
492         if [ -d /run/WSL ]; then interop=interop; mask='--tmpfs /run/WSL'; \
493         else interop=no-interop; mask=''; fi; \
494         \"$bwrap_path\" --ro-bind / / --tmpfs /tmp $mask --dev /dev --proc /proc \
495         --unshare-net --unshare-user --unshare-ipc --unshare-uts --unshare-pid \
496         --unshare-cgroup-try --die-with-parent -- true >/dev/null \
497         || exit {BWRAP_UNUSABLE_EXIT_CODE}; \
498         printf '{PROBE_RESULT_PREFIX} %s %s\\n' \"$interop\" \"$bwrap_path\""
499    )
500}
501
502/// Probe a distro's sandbox environment in one `wsl.exe` round-trip: confirm
503/// a shell starts, confirm `bwrap` is installed *and can actually set up an
504/// unprivileged sandbox* (see [`probe_script`]), and report whether the
505/// interop socket directory exists.
506///
507/// Successful results are cached per distro for the life of the process —
508/// like `linux_bubblewrap::is_available`, the answers can't realistically
509/// change while Zed runs. Failures are deliberately *not* cached so a user
510/// who installs `bwrap` (or lifts a user-namespace restriction) after seeing
511/// the error can retry the command without restarting Zed.
512async fn probe_environment(wsl_exe: &Path, distro: Option<&str>) -> Result<EnvironmentProbe> {
513    static CACHE: OnceLock<Mutex<HashMap<Option<String>, EnvironmentProbe>>> = OnceLock::new();
514    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
515
516    let key = distro.map(str::to_string);
517    if let Some(probe) = cache
518        .lock()
519        .unwrap_or_else(|poisoned| poisoned.into_inner())
520        .get(&key)
521    {
522        return Ok(probe.clone());
523    }
524
525    // A login shell (`-lc`) is used so a `bwrap` reachable only through a
526    // profile-managed PATH is still found; the resolved absolute path is
527    // reported back so the real invocation execs the same binary.
528    let script = probe_script();
529    let output = run_wsl_command(
530        wsl_exe,
531        distro,
532        ["--exec", "sh", "-lc", &script],
533        "probe the sandbox environment",
534    )
535    .await?;
536    if output.status.code() == Some(BWRAP_MISSING_EXIT_CODE) {
537        return Err(unavailable(format!(
538            "Bubblewrap (`bwrap`) is not installed in {}",
539            wsl_distro_label(distro)
540        )));
541    }
542    if output.status.code() == Some(BWRAP_UNUSABLE_EXIT_CODE) {
543        let stderr = String::from_utf8_lossy(&output.stderr);
544        let stderr = stderr.trim();
545        return Err(unavailable(format!(
546            "Bubblewrap (`bwrap`) is installed in {} but could not set up a sandbox — the \
547             distro may restrict unprivileged user namespaces (as Ubuntu 24.04's default \
548             AppArmor policy does){}",
549            wsl_distro_label(distro),
550            if stderr.is_empty() {
551                String::new()
552            } else {
553                format!(": {stderr}")
554            }
555        )));
556    }
557    if !output.status.success() {
558        return Err(unavailable(format!(
559            "failed to start a shell in {}{}",
560            wsl_distro_label(distro),
561            command_failure_details(output.status.code(), &output.stderr)
562        )));
563    }
564
565    let stdout = String::from_utf8_lossy(&output.stdout);
566    let probe = parse_probe_output(&stdout).map_err(|error| {
567        unavailable(format!(
568            "unexpected sandbox probe output from {}: {error:#}",
569            wsl_distro_label(distro)
570        ))
571    })?;
572    cache
573        .lock()
574        .unwrap_or_else(|poisoned| poisoned.into_inner())
575        .insert(key, probe.clone());
576    Ok(probe)
577}
578
579/// Parse [`probe_script`] output: the last [`PROBE_RESULT_PREFIX`]-marked
580/// line wins, so stdout noise from login-shell profile scripts (which runs
581/// before the script body) is ignored.
582fn parse_probe_output(stdout: &str) -> Result<EnvironmentProbe> {
583    let line = stdout
584        .lines()
585        .rev()
586        .find_map(|line| line.strip_prefix(PROBE_RESULT_PREFIX))
587        .with_context(|| format!("no probe result line in: {stdout:?}"))?;
588    let (interop, bwrap_path) = line
589        .trim_start()
590        .split_once(' ')
591        .with_context(|| format!("malformed probe result line: {line:?}"))?;
592    let mask_interop_dir = match interop {
593        "interop" => true,
594        "no-interop" => false,
595        _ => bail!("malformed probe result line: {line:?}"),
596    };
597    ensure!(
598        bwrap_path.starts_with('/'),
599        "`bwrap` resolved to {bwrap_path:?} rather than an absolute path; a shell \
600         alias or function named `bwrap` cannot be run with `wsl --exec`"
601    );
602    Ok(EnvironmentProbe {
603        mask_interop_dir,
604        bwrap_path: bwrap_path.to_string(),
605    })
606}
607
608/// Ensure a Linux `zed` of the given release `channel`/`version` is available
609/// inside WSL and return its absolute in-WSL path, to be `--exec`'d as the
610/// `--wsl-sandbox-helper`. Runs [`HELPER_PROVISION_SCRIPT`] (which downloads the
611/// matching release tarball into an off-`PATH` location on first use).
612///
613/// Successful resolutions are cached per `(distro, channel, version)` for the
614/// life of the process — once provisioned, the path won't change. Failures are
615/// not cached, so a user who installs `curl` (or fixes networking) after an
616/// error can retry without restarting Zed.
617async fn ensure_wsl_zed_helper(
618    wsl_exe: &Path,
619    distro: Option<&str>,
620    channel: &str,
621    version: &str,
622) -> Result<String> {
623    type HelperCache = HashMap<(Option<String>, String, String), String>;
624    static CACHE: OnceLock<Mutex<HelperCache>> = OnceLock::new();
625    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
626
627    let key = (
628        distro.map(str::to_string),
629        channel.to_string(),
630        version.to_string(),
631    );
632    if let Some(path) = cache
633        .lock()
634        .unwrap_or_else(|poisoned| poisoned.into_inner())
635        .get(&key)
636    {
637        return Ok(path.clone());
638    }
639
640    // A login shell (`-lc`) is used so a profile-managed PATH (where `zed` or
641    // `curl` may live) is honored. `channel`/`version` are passed as positional
642    // args (`$1`/`$2`), never interpolated into the script body.
643    let output = run_wsl_command(
644        wsl_exe,
645        distro,
646        [
647            "--exec",
648            "sh",
649            "-lc",
650            HELPER_PROVISION_SCRIPT,
651            "zed-wsl-sandbox-helper",
652            channel,
653            version,
654        ],
655        "provision the Linux `zed` sandbox helper",
656    )
657    .await?;
658    if !output.status.success() {
659        let stderr = String::from_utf8_lossy(&output.stderr);
660        let stderr = stderr.trim();
661        return Err(unavailable(format!(
662            "failed to provision a Linux `zed` sandbox helper in {}{}",
663            wsl_distro_label(distro),
664            if stderr.is_empty() {
665                String::new()
666            } else {
667                format!(": {stderr}")
668            }
669        )));
670    }
671
672    let stdout = String::from_utf8_lossy(&output.stdout);
673    let path = stdout
674        .lines()
675        .rev()
676        .find_map(|line| line.strip_prefix(HELPER_RESULT_PREFIX))
677        .map(|path| path.trim().to_string())
678        .with_context(|| {
679            unavailable(format!(
680                "no helper result line in sandbox-helper provisioning output from {}: {stdout:?}",
681                wsl_distro_label(distro)
682            ))
683        })?;
684    ensure!(
685        path.starts_with('/'),
686        "the WSL `zed` sandbox helper resolved to {path:?} rather than an absolute path"
687    );
688
689    cache
690        .lock()
691        .unwrap_or_else(|poisoned| poisoned.into_inner())
692        .insert(key, path.clone());
693    Ok(path)
694}
695
696/// Shell script that resolves and existence-checks paths in a single WSL
697/// round-trip. Arguments come in triples `(kind, path, fallback)`: kind `W`
698/// is a native Windows path to translate with `wslpath -u` (falling back to
699/// the precomputed `/mnt/<drive>/...` mapping when translation fails), kind
700/// `L` is an already-Linux path with an empty fallback. One result line is
701/// printed per triple: `<ok|fallback> <ok|missing> <resolved path>`.
702const PATH_RESOLUTION_SCRIPT: &str = "\
703    while [ \"$#\" -ge 3 ]; do \
704        kind=$1; path=$2; fallback=$3; shift 3; translate=ok; \
705        if [ \"$kind\" = W ]; then \
706            resolved=$(wslpath -u \"$path\" 2>/dev/null) || { resolved=$fallback; translate=fallback; }; \
707        else resolved=$path; fi; \
708        exists=ok; [ -e \"$resolved\" ] || exists=missing; \
709        printf '%s %s %s\\n' \"$translate\" \"$exists\" \"$resolved\"; \
710    done";
711
712/// A line of [`PATH_RESOLUTION_SCRIPT`] output, parsed.
713#[derive(Debug, Eq, PartialEq)]
714struct ResolvedPath {
715    path: String,
716    used_fallback: bool,
717    exists: bool,
718}
719
720/// Resolve path mappings into final WSL paths and confirm required paths exist.
721/// Native drive-letter paths are translated with `wslpath -u` inside the
722/// chosen distro so its actual automount configuration is honored, falling
723/// back to the structural `/mnt/<drive>` mapping when translation fails
724/// (e.g. a distro without `wslpath`); a wrong fallback is still caught by
725/// the existence check.
726///
727/// Successful resolutions are memoized per `(distro, mapping)` for the life
728/// of the process, so a steady-state command whose paths have all been seen
729/// before resolves with zero `wsl.exe` round-trips; at most one round-trip
730/// handles all cache misses ([`resolve_uncached_paths`]). A hit reuses the
731/// translation — which only changes if the distro's automount configuration
732/// is edited and the distro restarted — and also skips the WSL-side
733/// existence re-check. That staleness is acceptable: if a cached path
734/// disappears mid-session bwrap fails closed on the missing bind source rather
735/// than running the command unsandboxed. Optional missing paths are not cached,
736/// so a protected Git path can be created and then included by a later command.
737///
738/// Each mapping is paired with a human-readable description used in errors and
739/// a flag for whether the path is required to exist. The returned paths are in
740/// the same order as `mappings`; optional missing paths are returned as `None`.
741async fn resolve_paths(
742    wsl_exe: &Path,
743    distro: Option<&str>,
744    mappings: &[(PathMapping, &str, bool)],
745) -> Result<Vec<Option<String>>> {
746    type ResolutionCache = HashMap<Option<String>, HashMap<PathMapping, String>>;
747    static CACHE: OnceLock<Mutex<ResolutionCache>> = OnceLock::new();
748    let cache = CACHE.get_or_init(Default::default);
749
750    let distro_key = distro.map(str::to_string);
751    let mut resolved: Vec<Option<Option<String>>> = {
752        let cache = cache
753            .lock()
754            .unwrap_or_else(|poisoned| poisoned.into_inner());
755        let per_distro = cache.get(&distro_key);
756        mappings
757            .iter()
758            .map(|(mapping, _, _)| {
759                per_distro
760                    .and_then(|cached| cached.get(mapping))
761                    .cloned()
762                    .map(Some)
763            })
764            .collect()
765    };
766
767    let misses: Vec<usize> = (0..mappings.len())
768        .filter(|&index| resolved[index].is_none())
769        .collect();
770    if !misses.is_empty() {
771        let miss_mappings: Vec<&(PathMapping, &str, bool)> =
772            misses.iter().map(|&index| &mappings[index]).collect();
773        let miss_resolved = resolve_uncached_paths(wsl_exe, distro, &miss_mappings).await?;
774        let mut cache = cache
775            .lock()
776            .unwrap_or_else(|poisoned| poisoned.into_inner());
777        let per_distro = cache.entry(distro_key).or_default();
778        for (&index, path) in misses.iter().zip(miss_resolved) {
779            if let Some(path) = &path {
780                per_distro.insert(mappings[index].0.clone(), path.clone());
781            }
782            resolved[index] = Some(path);
783        }
784    }
785
786    let mut paths = Vec::with_capacity(resolved.len());
787    for path in resolved {
788        paths.push(path.context("bug: a path mapping was left unresolved")?);
789    }
790    Ok(paths)
791}
792
793/// Resolve and existence-check mappings that weren't in the cache, in a
794/// single `wsl.exe` round-trip. A non-login shell runs the script so profile
795/// scripts can't pollute the stdout protocol.
796async fn resolve_uncached_paths(
797    wsl_exe: &Path,
798    distro: Option<&str>,
799    mappings: &[&(PathMapping, &str, bool)],
800) -> Result<Vec<Option<String>>> {
801    let mut args = vec![
802        "--exec".to_string(),
803        "sh".to_string(),
804        "-c".to_string(),
805        PATH_RESOLUTION_SCRIPT.to_string(),
806        // argv[0] for the script; the path triples follow as "$@".
807        "zed-resolve-paths".to_string(),
808    ];
809    args.extend(path_resolution_args(
810        mappings.iter().map(|mapping| &mapping.0),
811    ));
812    let output = run_wsl_command(wsl_exe, distro, &args, "resolve sandbox paths").await?;
813    if !output.status.success() {
814        return Err(unavailable(format!(
815            "failed to resolve sandbox paths in {}{}",
816            wsl_distro_label(distro),
817            command_failure_details(output.status.code(), &output.stderr)
818        )));
819    }
820
821    let stdout = String::from_utf8_lossy(&output.stdout);
822    let resolved = parse_path_resolution_output(&stdout, mappings.len()).map_err(|error| {
823        unavailable(format!(
824            "failed to resolve sandbox paths in {}: {error:#}",
825            wsl_distro_label(distro)
826        ))
827    })?;
828
829    mappings
830        .iter()
831        .zip(resolved)
832        .map(|((mapping, description, required), resolved)| {
833            if resolved.used_fallback
834                && let PathMapping::NativeDrive { windows_path, .. } = mapping
835            {
836                log::warn!(
837                    "failed to translate `{windows_path}` with wslpath in {}; \
838                     falling back to `{}`",
839                    wsl_distro_label(distro),
840                    resolved.path
841                );
842            }
843            if !resolved.exists {
844                // A bad request (the path simply isn't there), not an
845                // environment problem — the model can create it or fix the path
846                // and retry, so no `WSL_SANDBOX_UNAVAILABLE_PREFIX`. Protected
847                // Git paths are allowed to be absent, matching Linux bwrap's
848                // behavior: a missing path cannot be overlaid, so it is skipped.
849                ensure!(
850                    !required,
851                    "mapped {description} `{}` does not exist in {}",
852                    resolved.path,
853                    wsl_distro_label(distro)
854                );
855                return Ok(None);
856            }
857            Ok(Some(resolved.path))
858        })
859        .collect()
860}
861
862/// Flatten path mappings into the `(kind, path, fallback)` argument triples
863/// consumed by [`PATH_RESOLUTION_SCRIPT`].
864fn path_resolution_args<'a>(mappings: impl Iterator<Item = &'a PathMapping>) -> Vec<String> {
865    let mut args = Vec::new();
866    for mapping in mappings {
867        match mapping {
868            PathMapping::Wsl(path) => {
869                args.extend(["L".to_string(), path.path.clone(), String::new()]);
870            }
871            PathMapping::NativeDrive {
872                windows_path,
873                fallback,
874            } => {
875                args.extend(["W".to_string(), windows_path.clone(), fallback.path.clone()]);
876            }
877        }
878    }
879    args
880}
881
882/// Parse [`PATH_RESOLUTION_SCRIPT`] output: one strictly-formatted line per
883/// input triple. Anything else (wrong line count, unknown status words, a
884/// non-absolute path) means the stdout protocol was corrupted and is an error.
885fn parse_path_resolution_output(stdout: &str, expected: usize) -> Result<Vec<ResolvedPath>> {
886    let lines: Vec<&str> = stdout.lines().collect();
887    ensure!(
888        lines.len() == expected,
889        "expected {expected} result lines from the path resolution script, got {}: {stdout:?}",
890        lines.len()
891    );
892    lines
893        .into_iter()
894        .map(|line| {
895            let mut parts = line.splitn(3, ' ');
896            let (Some(translate), Some(exists), Some(path)) =
897                (parts.next(), parts.next(), parts.next())
898            else {
899                bail!("malformed line from the path resolution script: {line:?}");
900            };
901            let used_fallback = match translate {
902                "ok" => false,
903                "fallback" => true,
904                _ => bail!("malformed line from the path resolution script: {line:?}"),
905            };
906            let exists = match exists {
907                "ok" => true,
908                "missing" => false,
909                _ => bail!("malformed line from the path resolution script: {line:?}"),
910            };
911            ensure!(
912                path.starts_with('/'),
913                "unexpected resolved path from the path resolution script: {path:?}"
914            );
915            Ok(ResolvedPath {
916                path: path.to_string(),
917                used_fallback,
918                exists,
919            })
920        })
921        .collect()
922}
923
924/// `CREATE_NO_WINDOW` process creation flag. `wsl.exe` is a console-subsystem
925/// binary, so spawning it from a GUI process without this flag flashes a
926/// console window. Defined locally because this crate doesn't depend on
927/// `util` (whose command helpers normally take care of this).
928const CREATE_NO_WINDOW: u32 = 0x0800_0000;
929
930/// Invoke `wsl.exe` with the given args and return its raw output.
931///
932/// Only spawn failures become errors here; callers interpret the exit status
933/// themselves. stdout, when used, is decoded as UTF-8 (lossily) — that's
934/// only valid for `--exec`'d programs whose output we control, not for
935/// `wsl.exe`'s own diagnostics (which are UTF-16LE).
936///
937/// `output()` spawns the child eagerly and the returned future owns it, so
938/// with `kill_on_drop` the child can't outlive this future: a caller-side
939/// timeout or cancellation that drops us also terminates a wedged `wsl.exe`
940/// instead of leaking it.
941async fn run_wsl_command(
942    wsl_exe: &Path,
943    distro: Option<&str>,
944    args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
945    description: &str,
946) -> Result<std::process::Output> {
947    use smol::process::windows::CommandExt as _;
948
949    let mut command = Command::new(wsl_exe);
950    if let Some(distro) = distro {
951        command.args(["-d", distro]);
952    }
953    command
954        .args(args)
955        .stdin(Stdio::null())
956        .kill_on_drop(true)
957        .creation_flags(CREATE_NO_WINDOW);
958
959    command.output().await.map_err(|error| {
960        unavailable(format!(
961            "failed to invoke WSL while trying to {description}: {error:#}"
962        ))
963    })
964}
965
966fn command_failure_details(exit_code: Option<i32>, stderr: &[u8]) -> String {
967    let stderr = String::from_utf8_lossy(stderr);
968    let stderr = stderr.trim();
969    let exit_status = match exit_code {
970        Some(code) => format!("exit code {code}"),
971        None => "terminated by signal".to_string(),
972    };
973    if stderr.is_empty() {
974        format!(" ({exit_status})")
975    } else {
976        format!(" ({exit_status}; stderr: {stderr})")
977    }
978}
979
980fn wsl_distro_label(distro: Option<&str>) -> String {
981    match distro {
982        Some(distro) => format!("WSL distro `{distro}`"),
983        None => "the default WSL distro".to_string(),
984    }
985}
986
987fn wsl_exe_path() -> PathBuf {
988    std::env::var_os("SystemRoot")
989        .map(PathBuf::from)
990        .unwrap_or_else(|| PathBuf::from(r"C:\Windows"))
991        .join("System32")
992        .join("wsl.exe")
993}
994
995fn build_bwrap_args<S: std::hash::BuildHasher>(
996    writable_paths: &[String],
997    protected_paths: &[String],
998    permissions: SandboxPermissions,
999    cwd: Option<&str>,
1000    mask_interop_dir: bool,
1001    env: &HashMap<String, String, S>,
1002) -> Vec<String> {
1003    let mut args = Vec::new();
1004
1005    if permissions.allow_fs_write {
1006        push_bind(&mut args, "--bind", "/", "/");
1007    } else {
1008        push_bind(&mut args, "--ro-bind", "/", "/");
1009        args.extend(["--tmpfs".to_string(), "/tmp".to_string()]);
1010        for path in writable_paths {
1011            push_bind(&mut args, "--bind", path, path);
1012        }
1013    }
1014
1015    // Protect requested paths by re-binding them read-only over any writable
1016    // binds above (order matters: later binds win).
1017    for path in protected_paths {
1018        push_bind(&mut args, "--ro-bind", path, path);
1019    }
1020
1021    // Block WSL's Windows interop, regardless of the requested permissions.
1022    // Without this, a sandboxed process can exec a Windows binary (e.g.
1023    // /mnt/c/Windows/System32/cmd.exe), which the kernel's binfmt handler
1024    // (`/init`) hands off to the Windows host over an AF_UNIX socket — running
1025    // fully outside bwrap and defeating both the filesystem and the network
1026    // restrictions. `/init` locates that socket via the $WSL_INTEROP
1027    // environment variable, so we drop it; and we mask the socket directory
1028    // (when it exists) so the value can't be rediscovered by listing
1029    // /run/WSL and re-exporting it. Both steps are required: unsetting the
1030    // variable alone is bypassable, and masking alone leaves the inherited
1031    // variable usable.
1032    args.extend(["--unsetenv".to_string(), "WSL_INTEROP".to_string()]);
1033    args.extend(["--unsetenv".to_string(), "WSLENV".to_string()]);
1034    if mask_interop_dir {
1035        args.extend(["--tmpfs".to_string(), "/run/WSL".to_string()]);
1036    }
1037
1038    args.extend([
1039        "--dev".to_string(),
1040        "/dev".to_string(),
1041        "--proc".to_string(),
1042        "/proc".to_string(),
1043    ]);
1044
1045    if !permissions.allow_network {
1046        args.push("--unshare-net".to_string());
1047    }
1048
1049    args.extend([
1050        "--unshare-user".to_string(),
1051        "--unshare-ipc".to_string(),
1052        "--unshare-uts".to_string(),
1053        "--unshare-pid".to_string(),
1054        "--unshare-cgroup-try".to_string(),
1055        "--die-with-parent".to_string(),
1056    ]);
1057
1058    // Forward the caller-provided environment into the command. Windows env
1059    // set on the `wsl.exe` process doesn't reach the Linux command, so we
1060    // re-apply it here on the sandbox's child instead.
1061    for (name, value) in env {
1062        if is_forwardable_env_var(name) {
1063            args.extend(["--setenv".to_string(), name.clone(), value.clone()]);
1064        }
1065    }
1066
1067    if let Some(cwd) = cwd {
1068        args.extend(["--chdir".to_string(), cwd.to_string()]);
1069    }
1070
1071    args
1072}
1073
1074/// Whether an environment variable should be forwarded into the Linux sandbox.
1075///
1076/// `bwrap --setenv` calls `setenv(3)`, which rejects names that are empty or
1077/// contain `=`. Windows process environments include such entries — most
1078/// notably the per-drive current-directory pseudo-variables (`=C:`, `=D:`,
1079/// ...) Windows keeps in the environment block — so they must be skipped or
1080/// bwrap aborts with "setenv failed".
1081///
1082/// Beyond that, many variables hold Windows-specific values that would be
1083/// meaningless or actively break the command inside WSL, so they are dropped
1084/// rather than forwarded: `PATH`/`PATHEXT` would shadow WSL's own `PATH` and
1085/// stop the shell from finding Linux executables, the temp-dir variables point
1086/// at Windows paths that don't exist in WSL (bwrap provides a fresh tmpfs
1087/// `/tmp` instead), the WSL interop variables would undermine the explicit
1088/// interop block above, a Windows-set `HOME` would clobber the distro's own
1089/// `$HOME` and break the shell, and the rest are Windows system locations,
1090/// shell settings, host/session identity, and CPU descriptors that are either
1091/// wrong or misleading inside Linux. This is a blocklist rather than an allowlist so
1092/// genuinely portable variables (e.g. `LANG`, `CARGO_TERM_COLOR`, `PAGER`)
1093/// still reach the command; it only needs to cover the variables Windows
1094/// populates by default. Matched case-insensitively because Windows
1095/// environment variable names are.
1096fn is_forwardable_env_var(name: &str) -> bool {
1097    if name.is_empty() || name.contains('=') {
1098        return false;
1099    }
1100    const BLOCKED: &[&str] = &[
1101        // Shadow or break the Linux process environment.
1102        "PATH",
1103        "PATHEXT",
1104        "TMPDIR",
1105        "TMP",
1106        "TEMP",
1107        // Would undermine the explicit WSL interop block above.
1108        "WSL_INTEROP",
1109        "WSLENV",
1110        // Windows system locations and shell, meaningless inside Linux.
1111        "OS",
1112        "COMSPEC",
1113        "WINDIR",
1114        "SYSTEMROOT",
1115        "SYSTEMDRIVE",
1116        // When Windows has `HOME` set (e.g. for git), it's a Windows path that
1117        // would clobber the distro's correct Linux `$HOME` and break the shell.
1118        "HOME",
1119        "HOMEDRIVE",
1120        "HOMEPATH",
1121        "HOMESHARE",
1122        "USERPROFILE",
1123        "PUBLIC",
1124        "ALLUSERSPROFILE",
1125        "APPDATA",
1126        "LOCALAPPDATA",
1127        "PROGRAMDATA",
1128        "PROGRAMFILES",
1129        "PROGRAMFILES(X86)",
1130        "PROGRAMW6432",
1131        "COMMONPROGRAMFILES",
1132        "COMMONPROGRAMFILES(X86)",
1133        "COMMONPROGRAMW6432",
1134        "PSMODULEPATH",
1135        "DRIVERDATA",
1136        "ONEDRIVE",
1137        // Windows host/session identity, misleading inside Linux.
1138        "COMPUTERNAME",
1139        "USERNAME",
1140        "USERDOMAIN",
1141        "USERDOMAIN_ROAMINGPROFILE",
1142        "LOGONSERVER",
1143        "SESSIONNAME",
1144        // Windows CPU descriptors.
1145        "NUMBER_OF_PROCESSORS",
1146        "PROCESSOR_ARCHITECTURE",
1147        "PROCESSOR_ARCHITEW6432",
1148        "PROCESSOR_IDENTIFIER",
1149        "PROCESSOR_LEVEL",
1150        "PROCESSOR_REVISION",
1151    ];
1152    !BLOCKED
1153        .iter()
1154        .any(|blocked| name.eq_ignore_ascii_case(blocked))
1155}
1156
1157fn push_bind(args: &mut Vec<String>, flag: &str, source: &str, destination: &str) {
1158    args.extend([
1159        flag.to_string(),
1160        source.to_string(),
1161        destination.to_string(),
1162    ]);
1163}
1164
1165fn directory_to_wsl(path: &Path) -> Result<PathMapping> {
1166    ensure!(
1167        path.is_dir(),
1168        "Windows sandboxing via WSL can only use an existing directory as cwd: {}",
1169        path.display()
1170    );
1171    map_path_to_wsl(path)
1172}
1173
1174fn path_to_wsl(path: &Path) -> Result<PathMapping> {
1175    let path_string = path.to_string_lossy();
1176    if let Ok(path) = parse_wsl_absolute_path(&path_string) {
1177        return Ok(PathMapping::Wsl(path));
1178    }
1179
1180    ensure!(
1181        path.is_dir() || path.is_file(),
1182        "Windows sandboxing via WSL can only grant existing files or directories: {}",
1183        path.display()
1184    );
1185    map_path_to_wsl(path)
1186}
1187
1188fn path_to_wsl_allowing_missing(path: &Path) -> Result<PathMapping> {
1189    let path_string = path.to_string_lossy();
1190    if let Ok(path) = parse_wsl_absolute_path(&path_string) {
1191        return Ok(PathMapping::Wsl(path));
1192    }
1193    map_path_to_wsl(path)
1194}
1195
1196fn map_path_to_wsl(path: &Path) -> Result<PathMapping> {
1197    let path_string = path.to_string_lossy();
1198    if let Ok(path) = parse_wsl_unc_path(&path_string) {
1199        return Ok(PathMapping::Wsl(path));
1200    }
1201    let fallback = parse_native_drive_path(&path_string)?;
1202    let windows_path = path_string
1203        .strip_prefix(r"\\?\")
1204        .unwrap_or(&path_string)
1205        .replace('\\', "/");
1206    Ok(PathMapping::NativeDrive {
1207        windows_path,
1208        fallback,
1209    })
1210}
1211
1212fn parse_wsl_absolute_path(path: &str) -> Result<WslPath> {
1213    let path = path.replace('\\', "/");
1214    ensure!(
1215        path.starts_with('/') && !path.starts_with("//"),
1216        "path is not a WSL absolute path: {path}"
1217    );
1218    Ok(WslPath { distro: None, path })
1219}
1220
1221fn parse_wsl_unc_path(path: &str) -> Result<WslPath> {
1222    let path = path.replace('/', "\\");
1223    let remainder = path
1224        .strip_prefix("\\\\wsl.localhost\\")
1225        .or_else(|| path.strip_prefix("\\\\wsl$\\"))
1226        .or_else(|| path.strip_prefix("\\\\?\\UNC\\wsl.localhost\\"))
1227        .or_else(|| path.strip_prefix("\\\\?\\UNC\\wsl$\\"))
1228        .with_context(|| format!("path is not a WSL UNC path: {path}"))?;
1229
1230    let (distro, rest) = remainder
1231        .split_once('\\')
1232        .map(|(distro, rest)| (distro, Some(rest)))
1233        .unwrap_or((remainder, None));
1234    ensure!(
1235        !distro.is_empty(),
1236        "WSL UNC path is missing a distro name: {path}"
1237    );
1238
1239    let linux_path = match rest {
1240        Some(rest) if !rest.is_empty() => format!("/{}", rest.replace('\\', "/")),
1241        _ => "/".to_string(),
1242    };
1243
1244    Ok(WslPath {
1245        distro: Some(distro.to_string()),
1246        path: linux_path,
1247    })
1248}
1249
1250fn parse_native_drive_path(path: &str) -> Result<WslPath> {
1251    let path = path
1252        .strip_prefix("\\\\?\\")
1253        .unwrap_or(path)
1254        .replace('\\', "/");
1255    let mut chars = path.chars();
1256    let Some(drive) = chars.next().filter(|drive| drive.is_ascii_alphabetic()) else {
1257        bail!("path is not a drive-letter Windows path: {path}");
1258    };
1259    ensure!(chars.next() == Some(':'), "path is not absolute: {path}");
1260    let rest = chars.as_str().trim_start_matches('/');
1261    let drive = drive.to_ascii_lowercase();
1262    let linux_path = if rest.is_empty() {
1263        format!("/mnt/{drive}")
1264    } else {
1265        format!("/mnt/{drive}/{rest}")
1266    };
1267    Ok(WslPath {
1268        distro: None,
1269        path: linux_path,
1270    })
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276
1277    #[test]
1278    fn wrap_invocation_future_is_send() {
1279        // Callers run `wrap_invocation` via `background_spawn`, which
1280        // requires a `Send` future. This fails to compile if, for example, a
1281        // cache `MutexGuard` is ever held across an await point.
1282        fn assert_send<T: Send>(_: T) {}
1283        assert_send(wrap_invocation(
1284            String::new(),
1285            Vec::new(),
1286            Vec::new(),
1287            Vec::new(),
1288            SandboxPermissions::default(),
1289            None,
1290            HashMap::<String, String>::new(),
1291            None,
1292        ));
1293    }
1294
1295    #[test]
1296    fn parse_wsl_localhost_path() {
1297        let path = parse_wsl_unc_path(r"\\wsl.localhost\Ubuntu\home\me\project").unwrap();
1298        assert_eq!(path.distro.as_deref(), Some("Ubuntu"));
1299        assert_eq!(path.path, "/home/me/project");
1300    }
1301
1302    #[test]
1303    fn parse_wsl_dollar_path() {
1304        let path = parse_wsl_unc_path(r"\\wsl$\Debian\tmp").unwrap();
1305        assert_eq!(path.distro.as_deref(), Some("Debian"));
1306        assert_eq!(path.path, "/tmp");
1307    }
1308
1309    #[test]
1310    fn parse_native_windows_path() {
1311        let path = parse_native_drive_path(r"C:\Users\me\project").unwrap();
1312        assert_eq!(path.distro, None);
1313        assert_eq!(path.path, "/mnt/c/Users/me/project");
1314    }
1315
1316    #[test]
1317    fn parse_wsl_absolute_path_keeps_linux_path() {
1318        let path = parse_wsl_absolute_path("/home/me").unwrap();
1319        assert_eq!(path.distro, None);
1320        assert_eq!(path.path, "/home/me");
1321    }
1322
1323    #[test]
1324    fn parse_wsl_absolute_path_rejects_unc_paths() {
1325        assert!(parse_wsl_absolute_path(r"\\server\share").is_err());
1326    }
1327
1328    #[test]
1329    fn parse_verbatim_native_windows_path() {
1330        let path = parse_native_drive_path(r"\\?\D:\workspace").unwrap();
1331        assert_eq!(path.distro, None);
1332        assert_eq!(path.path, "/mnt/d/workspace");
1333    }
1334
1335    #[test]
1336    fn rejects_unc_non_wsl_path() {
1337        assert!(parse_native_drive_path(r"\\server\share\project").is_err());
1338    }
1339
1340    #[test]
1341    fn probe_output_reports_interop_and_bwrap_path() {
1342        let probe = parse_probe_output("zed-wsl-probe: interop /usr/bin/bwrap\n").unwrap();
1343        assert_eq!(
1344            probe,
1345            EnvironmentProbe {
1346                mask_interop_dir: true,
1347                bwrap_path: "/usr/bin/bwrap".to_string(),
1348            }
1349        );
1350
1351        let probe =
1352            parse_probe_output("zed-wsl-probe: no-interop /home/me/.nix-profile/bin/bwrap\n")
1353                .unwrap();
1354        assert_eq!(
1355            probe,
1356            EnvironmentProbe {
1357                mask_interop_dir: false,
1358                bwrap_path: "/home/me/.nix-profile/bin/bwrap".to_string(),
1359            }
1360        );
1361    }
1362
1363    #[test]
1364    fn probe_output_ignores_profile_noise_even_mentioning_interop() {
1365        // Login-shell profile scripts run before the probe body and may print
1366        // arbitrary text; only the marked result line counts.
1367        let probe = parse_probe_output(
1368            "welcome to my shell, interop fans\nzed-wsl-probe: no-interop /usr/bin/bwrap\n",
1369        )
1370        .unwrap();
1371        assert!(!probe.mask_interop_dir);
1372    }
1373
1374    #[test]
1375    fn probe_output_rejects_missing_or_malformed_result_line() {
1376        assert!(parse_probe_output("").is_err());
1377        assert!(parse_probe_output("profile noise only\n").is_err());
1378        assert!(parse_probe_output("zed-wsl-probe: interop\n").is_err());
1379        assert!(parse_probe_output("zed-wsl-probe: maybe /usr/bin/bwrap\n").is_err());
1380    }
1381
1382    #[test]
1383    fn probe_output_rejects_non_absolute_bwrap_path() {
1384        // `command -v` reports a bare name for shell functions and aliases,
1385        // which `wsl --exec` could never run.
1386        assert!(parse_probe_output("zed-wsl-probe: interop bwrap\n").is_err());
1387    }
1388
1389    #[test]
1390    fn probe_script_smoke_tests_the_namespaces_the_real_invocation_uses() {
1391        // Presence isn't enough: unprivileged user namespaces can be
1392        // restricted (e.g. Ubuntu 24.04's AppArmor policy), so the probe must
1393        // actually exercise the namespace flags `build_bwrap_args` emits.
1394        let script = probe_script();
1395        for flag in [
1396            "--unshare-user",
1397            "--unshare-net",
1398            "--unshare-ipc",
1399            "--unshare-uts",
1400            "--unshare-pid",
1401            "--unshare-cgroup-try",
1402            "--ro-bind / /",
1403        ] {
1404            assert!(script.contains(flag), "probe script must contain {flag}");
1405        }
1406        assert!(script.contains("exit 41"));
1407        assert!(script.contains("exit 42"));
1408    }
1409
1410    #[test]
1411    fn probe_script_rejects_setuid_root_bwrap_before_smoke_test() {
1412        let script = probe_script();
1413        let guard =
1414            "[ -u \"$bwrap_path\" ] && [ \"$(stat -c %u \"$bwrap_path\" 2>/dev/null)\" = 0 ]";
1415        let smoke_test = "\"$bwrap_path\" --ro-bind / /";
1416        let Some(guard_index) = script.find(guard) else {
1417            panic!("probe script must contain setuid-root guard: {script}");
1418        };
1419        let Some(smoke_test_index) = script.find(smoke_test) else {
1420            panic!("probe script must contain bwrap smoke test: {script}");
1421        };
1422
1423        assert!(script.contains("setuid-root bwrap is not supported"));
1424        assert!(script.contains(&format!("exit {BWRAP_UNUSABLE_EXIT_CODE}; fi")));
1425        assert!(guard_index < smoke_test_index);
1426    }
1427
1428    #[test]
1429    fn split_resolved_paths_keeps_existing_protected_paths_and_skips_missing_ones() {
1430        let (cwd, writable_paths, protected_paths) = split_resolved_paths(
1431            true,
1432            1,
1433            vec![
1434                Some("/home/me/project".to_string()),
1435                Some("/home/me/project".to_string()),
1436                None,
1437                Some("/mnt/c/external/.git".to_string()),
1438            ],
1439        )
1440        .unwrap();
1441
1442        assert_eq!(cwd.as_deref(), Some("/home/me/project"));
1443        assert_eq!(writable_paths, vec!["/home/me/project".to_string()]);
1444        assert_eq!(protected_paths, vec!["/mnt/c/external/.git".to_string()]);
1445    }
1446
1447    #[test]
1448    fn split_resolved_paths_rejects_missing_required_writable_paths() {
1449        let error = split_resolved_paths(false, 1, vec![None]).unwrap_err();
1450        assert!(
1451            error
1452                .to_string()
1453                .contains("required writable path resolved as missing"),
1454            "unexpected error: {error:#}"
1455        );
1456    }
1457
1458    #[test]
1459    fn bwrap_denies_network_by_default() {
1460        let args = build_bwrap_args(
1461            &["/home/me/project".to_string()],
1462            &[],
1463            SandboxPermissions::default(),
1464            Some("/home/me/project"),
1465            true,
1466            &HashMap::new(),
1467        );
1468        assert!(args.iter().any(|arg| arg == "--unshare-net"));
1469        assert!(
1470            args.windows(3)
1471                .any(|window| window == ["--bind", "/home/me/project", "/home/me/project"])
1472        );
1473    }
1474
1475    #[test]
1476    fn bwrap_allows_network_when_requested() {
1477        let args = build_bwrap_args(
1478            &[],
1479            &[],
1480            SandboxPermissions {
1481                allow_network: true,
1482                allow_fs_write: false,
1483            },
1484            None,
1485            true,
1486            &HashMap::new(),
1487        );
1488        assert!(!args.iter().any(|arg| arg == "--unshare-net"));
1489    }
1490
1491    #[test]
1492    fn bwrap_binds_explicit_writable_file_paths() {
1493        let args = build_bwrap_args(
1494            &["/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md".to_string()],
1495            &[],
1496            SandboxPermissions::default(),
1497            None,
1498            true,
1499            &HashMap::new(),
1500        );
1501        assert!(args.windows(3).any(|window| window
1502            == [
1503                "--bind",
1504                "/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md",
1505                "/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md"
1506            ]));
1507    }
1508
1509    #[test]
1510    fn bwrap_protects_paths_after_writable_paths() {
1511        let args = build_bwrap_args(
1512            &["/home/me/project".to_string()],
1513            &["/home/me/project/.git".to_string()],
1514            SandboxPermissions::default(),
1515            Some("/home/me/project"),
1516            true,
1517            &HashMap::new(),
1518        );
1519        let writable_index = args
1520            .windows(3)
1521            .position(|window| window == ["--bind", "/home/me/project", "/home/me/project"])
1522            .expect("project should be writable");
1523        let protected_index = args
1524            .windows(3)
1525            .position(|window| {
1526                window
1527                    == [
1528                        "--ro-bind",
1529                        "/home/me/project/.git",
1530                        "/home/me/project/.git",
1531                    ]
1532            })
1533            .expect("protected path should be bound read-only");
1534        assert!(protected_index > writable_index);
1535    }
1536
1537    #[test]
1538    fn bwrap_protects_paths_when_fs_writes_are_unrestricted() {
1539        let args = build_bwrap_args(
1540            &[],
1541            &["/home/me/project/.git".to_string()],
1542            SandboxPermissions {
1543                allow_network: false,
1544                allow_fs_write: true,
1545            },
1546            None,
1547            true,
1548            &HashMap::new(),
1549        );
1550        let unrestricted_write_index = args
1551            .windows(3)
1552            .position(|window| window == ["--bind", "/", "/"])
1553            .expect("root should be writable");
1554        let protected_index = args
1555            .windows(3)
1556            .position(|window| {
1557                window
1558                    == [
1559                        "--ro-bind",
1560                        "/home/me/project/.git",
1561                        "/home/me/project/.git",
1562                    ]
1563            })
1564            .expect("protected path should be bound read-only");
1565        assert!(protected_index > unrestricted_write_index);
1566    }
1567
1568    #[test]
1569    fn bwrap_blocks_wsl_interop_by_default() {
1570        let args = build_bwrap_args(
1571            &["/home/me/project".to_string()],
1572            &[],
1573            SandboxPermissions::default(),
1574            Some("/home/me/project"),
1575            true,
1576            &HashMap::new(),
1577        );
1578        assert!(
1579            args.windows(2)
1580                .any(|window| window == ["--unsetenv", "WSL_INTEROP"])
1581        );
1582        assert!(
1583            args.windows(2)
1584                .any(|window| window == ["--tmpfs", "/run/WSL"])
1585        );
1586    }
1587
1588    #[test]
1589    fn bwrap_blocks_wsl_interop_even_with_fs_write() {
1590        let args = build_bwrap_args(
1591            &[],
1592            &[],
1593            SandboxPermissions {
1594                allow_network: true,
1595                allow_fs_write: true,
1596            },
1597            None,
1598            true,
1599            &HashMap::new(),
1600        );
1601        // Interop is host code execution, not just a filesystem write, so it
1602        // stays blocked even when the user has granted unrestricted writes
1603        // and network.
1604        assert!(
1605            args.windows(2)
1606                .any(|window| window == ["--unsetenv", "WSL_INTEROP"])
1607        );
1608        assert!(
1609            args.windows(2)
1610                .any(|window| window == ["--tmpfs", "/run/WSL"])
1611        );
1612    }
1613
1614    #[test]
1615    fn bwrap_skips_interop_dir_mask_when_absent() {
1616        // When the interop socket directory doesn't exist (interop disabled),
1617        // there's nothing to mask and a `--tmpfs /run/WSL` would abort bwrap,
1618        // so the mount must be omitted. Unsetting the variable is harmless and
1619        // stays.
1620        let args = build_bwrap_args(
1621            &[],
1622            &[],
1623            SandboxPermissions::default(),
1624            None,
1625            false,
1626            &HashMap::new(),
1627        );
1628        assert!(
1629            args.windows(2)
1630                .any(|window| window == ["--unsetenv", "WSL_INTEROP"])
1631        );
1632        assert!(!args.iter().any(|arg| arg == "/run/WSL"));
1633    }
1634
1635    #[test]
1636    fn bwrap_forwards_env_via_setenv() {
1637        let env = HashMap::from([
1638            ("PAGER".to_string(), String::new()),
1639            ("CARGO_TERM_COLOR".to_string(), "always".to_string()),
1640        ]);
1641        let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1642        assert!(
1643            args.windows(3)
1644                .any(|window| window == ["--setenv", "PAGER", ""])
1645        );
1646        assert!(
1647            args.windows(3)
1648                .any(|window| window == ["--setenv", "CARGO_TERM_COLOR", "always"])
1649        );
1650    }
1651
1652    #[test]
1653    fn bwrap_does_not_forward_wsl_interop_env() {
1654        let env = HashMap::from([
1655            (
1656                "WSL_INTEROP".to_string(),
1657                "/run/WSL/123_interop".to_string(),
1658            ),
1659            ("WsLeNv".to_string(), "WSL_INTEROP/u".to_string()),
1660            ("PAGER".to_string(), String::new()),
1661        ]);
1662        let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1663
1664        assert!(
1665            args.windows(2)
1666                .any(|window| window == ["--unsetenv", "WSL_INTEROP"])
1667        );
1668        assert!(
1669            args.windows(2)
1670                .any(|window| window == ["--unsetenv", "WSLENV"])
1671        );
1672        assert!(
1673            args.windows(3)
1674                .any(|window| window == ["--setenv", "PAGER", ""])
1675        );
1676        assert!(!args.windows(3).any(|window| {
1677            matches!(window, [flag, name, _]
1678                if flag.as_str() == "--setenv"
1679                    && name.eq_ignore_ascii_case("WSL_INTEROP"))
1680        }));
1681        assert!(!args.windows(3).any(|window| {
1682            matches!(window, [flag, name, _]
1683                if flag.as_str() == "--setenv"
1684                    && name.eq_ignore_ascii_case("WSLENV"))
1685        }));
1686    }
1687
1688    #[test]
1689    fn bwrap_does_not_forward_windows_specific_env() {
1690        // These hold Windows paths/values that would break or be meaningless
1691        // inside WSL, so they must never cross the boundary. Names are matched
1692        // case-insensitively, as Windows env var names are. `(x86)` variants
1693        // contain parentheses but no `=`, so they'd pass the `setenv` filter
1694        // and must be blocked by name.
1695        let env = HashMap::from([
1696            ("Path".to_string(), r"C:\Windows\System32".to_string()),
1697            ("PATHEXT".to_string(), ".COM;.EXE;.BAT".to_string()),
1698            (
1699                "TEMP".to_string(),
1700                r"C:\Users\me\AppData\Local\Temp".to_string(),
1701            ),
1702            (
1703                "Tmp".to_string(),
1704                r"C:\Users\me\AppData\Local\Temp".to_string(),
1705            ),
1706            ("TMPDIR".to_string(), r"C:\tmp".to_string()),
1707            ("OS".to_string(), "Windows_NT".to_string()),
1708            (
1709                "ComSpec".to_string(),
1710                r"C:\Windows\system32\cmd.exe".to_string(),
1711            ),
1712            ("windir".to_string(), r"C:\Windows".to_string()),
1713            ("SystemRoot".to_string(), r"C:\Windows".to_string()),
1714            ("HOME".to_string(), r"C:\Users\me".to_string()),
1715            ("USERPROFILE".to_string(), r"C:\Users\me".to_string()),
1716            (
1717                "APPDATA".to_string(),
1718                r"C:\Users\me\AppData\Roaming".to_string(),
1719            ),
1720            (
1721                "LOCALAPPDATA".to_string(),
1722                r"C:\Users\me\AppData\Local".to_string(),
1723            ),
1724            (
1725                "ProgramFiles(x86)".to_string(),
1726                r"C:\Program Files (x86)".to_string(),
1727            ),
1728            ("USERNAME".to_string(), "me".to_string()),
1729            ("COMPUTERNAME".to_string(), "DESKTOP-ABC".to_string()),
1730            ("PROCESSOR_ARCHITECTURE".to_string(), "AMD64".to_string()),
1731            ("NUMBER_OF_PROCESSORS".to_string(), "16".to_string()),
1732        ]);
1733        let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1734        assert!(!args.iter().any(|arg| arg == "--setenv"));
1735    }
1736
1737    #[test]
1738    fn bwrap_forwards_portable_env_alongside_windows_specific_env() {
1739        // A blocklist (not an allowlist) means genuinely portable variables
1740        // still reach the command even when Windows-only ones are present.
1741        let env = HashMap::from([
1742            ("USERPROFILE".to_string(), r"C:\Users\me".to_string()),
1743            ("LANG".to_string(), "en_US.UTF-8".to_string()),
1744            ("CARGO_TERM_COLOR".to_string(), "always".to_string()),
1745        ]);
1746        let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1747        assert!(
1748            args.windows(3)
1749                .any(|window| window == ["--setenv", "LANG", "en_US.UTF-8"])
1750        );
1751        assert!(
1752            args.windows(3)
1753                .any(|window| window == ["--setenv", "CARGO_TERM_COLOR", "always"])
1754        );
1755        assert!(!args.windows(3).any(|window| {
1756            matches!(window, [flag, name, _]
1757                if flag.as_str() == "--setenv"
1758                    && name.eq_ignore_ascii_case("USERPROFILE"))
1759        }));
1760    }
1761
1762    #[test]
1763    fn bwrap_skips_env_names_setenv_would_reject() {
1764        // bwrap's `--setenv` calls `setenv(3)`, which rejects empty names and
1765        // names containing `=`. Windows environments include the per-drive
1766        // current-directory pseudo-variables (`=C:`, ...); forwarding them
1767        // would abort bwrap with "setenv failed".
1768        let env = HashMap::from([
1769            ("=C:".to_string(), r"C:\Users\me".to_string()),
1770            (String::new(), "value".to_string()),
1771            ("OK".to_string(), "value".to_string()),
1772        ]);
1773        let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1774        assert!(
1775            args.windows(3)
1776                .any(|window| window == ["--setenv", "OK", "value"])
1777        );
1778        assert_eq!(args.iter().filter(|arg| *arg == "--setenv").count(), 1);
1779    }
1780
1781    #[test]
1782    fn select_distro_uses_wsl_distro_when_present() {
1783        let distro = select_distro(
1784            None,
1785            &[
1786                PathMapping::NativeDrive {
1787                    windows_path: "C:/project".to_string(),
1788                    fallback: WslPath {
1789                        distro: None,
1790                        path: "/mnt/c/project".to_string(),
1791                    },
1792                },
1793                PathMapping::Wsl(WslPath {
1794                    distro: Some("Ubuntu".to_string()),
1795                    path: "/home/me/project".to_string(),
1796                }),
1797            ],
1798        )
1799        .unwrap();
1800        assert_eq!(distro.as_deref(), Some("Ubuntu"));
1801    }
1802
1803    #[test]
1804    fn bad_request_errors_do_not_claim_sandboxing_is_unavailable() {
1805        // Mixed distros and missing/unmappable paths are model-fixable bad
1806        // requests. They must not be typed as `WslSandboxUnavailable` (nor
1807        // carry its prefix), since the agent uses that type to offer the
1808        // run-unsandboxed fallback only for genuine environment failures.
1809        let mixed_distros = select_distro(
1810            Some(&PathMapping::Wsl(WslPath {
1811                distro: Some("Ubuntu".to_string()),
1812                path: "/home/me".to_string(),
1813            })),
1814            &[PathMapping::Wsl(WslPath {
1815                distro: Some("Debian".to_string()),
1816                path: "/home/me".to_string(),
1817            })],
1818        )
1819        .unwrap_err();
1820        assert!(
1821            mixed_distros
1822                .downcast_ref::<WslSandboxUnavailable>()
1823                .is_none()
1824        );
1825        assert!(!format!("{mixed_distros:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
1826
1827        let missing_path =
1828            path_to_wsl(Path::new(r"C:\zed-test\definitely\does\not\exist-2769")).unwrap_err();
1829        assert!(
1830            missing_path
1831                .downcast_ref::<WslSandboxUnavailable>()
1832                .is_none()
1833        );
1834        assert!(!format!("{missing_path:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
1835
1836        let unmappable_cwd = directory_to_wsl(Path::new(r"\\server\share\project")).unwrap_err();
1837        assert!(
1838            unmappable_cwd
1839                .downcast_ref::<WslSandboxUnavailable>()
1840                .is_none()
1841        );
1842        assert!(!format!("{unmappable_cwd:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
1843    }
1844
1845    #[test]
1846    fn unavailable_errors_are_typed_and_prefixed() {
1847        // Environment failures are recognizable by type (so the agent doesn't
1848        // depend on message text) and still render with the shared prefix.
1849        let error = unavailable("Bubblewrap (`bwrap`) is not installed in the default WSL distro");
1850        let typed = error
1851            .downcast_ref::<WslSandboxUnavailable>()
1852            .expect("environment failure should downcast to WslSandboxUnavailable");
1853        assert_eq!(
1854            typed.message(),
1855            "Bubblewrap (`bwrap`) is not installed in the default WSL distro"
1856        );
1857        assert!(format!("{error:#}").starts_with(WSL_SANDBOX_UNAVAILABLE_PREFIX));
1858    }
1859
1860    #[test]
1861    fn map_path_to_wsl_keeps_unc_paths_structural() {
1862        let mapping = map_path_to_wsl(Path::new(r"\\wsl.localhost\Ubuntu\home\me")).unwrap();
1863        assert_eq!(
1864            mapping,
1865            PathMapping::Wsl(WslPath {
1866                distro: Some("Ubuntu".to_string()),
1867                path: "/home/me".to_string(),
1868            })
1869        );
1870    }
1871
1872    #[test]
1873    fn map_path_to_wsl_defers_native_paths_to_wslpath() {
1874        let mapping = map_path_to_wsl(Path::new(r"C:\Users\me\project")).unwrap();
1875        assert_eq!(
1876            mapping,
1877            PathMapping::NativeDrive {
1878                windows_path: "C:/Users/me/project".to_string(),
1879                fallback: WslPath {
1880                    distro: None,
1881                    path: "/mnt/c/Users/me/project".to_string(),
1882                },
1883            }
1884        );
1885    }
1886
1887    #[test]
1888    fn map_path_to_wsl_strips_verbatim_prefix_for_wslpath() {
1889        let mapping = map_path_to_wsl(Path::new(r"\\?\D:\workspace")).unwrap();
1890        assert_eq!(
1891            mapping,
1892            PathMapping::NativeDrive {
1893                windows_path: "D:/workspace".to_string(),
1894                fallback: WslPath {
1895                    distro: None,
1896                    path: "/mnt/d/workspace".to_string(),
1897                },
1898            }
1899        );
1900    }
1901
1902    #[test]
1903    fn path_resolution_args_flattens_mappings_into_triples() {
1904        let mappings = [
1905            PathMapping::NativeDrive {
1906                windows_path: "C:/Users/me/project".to_string(),
1907                fallback: WslPath {
1908                    distro: None,
1909                    path: "/mnt/c/Users/me/project".to_string(),
1910                },
1911            },
1912            PathMapping::Wsl(WslPath {
1913                distro: Some("Ubuntu".to_string()),
1914                path: "/home/me/project".to_string(),
1915            }),
1916        ];
1917        assert_eq!(
1918            path_resolution_args(mappings.iter()),
1919            [
1920                "W",
1921                "C:/Users/me/project",
1922                "/mnt/c/Users/me/project",
1923                "L",
1924                "/home/me/project",
1925                "",
1926            ]
1927        );
1928    }
1929
1930    #[test]
1931    fn parse_path_resolution_output_reads_one_line_per_path() {
1932        let resolved = parse_path_resolution_output(
1933            "ok ok /mnt/c/Users/me/project\nfallback missing /mnt/d/workspace\n",
1934            2,
1935        )
1936        .unwrap();
1937        assert_eq!(
1938            resolved,
1939            [
1940                ResolvedPath {
1941                    path: "/mnt/c/Users/me/project".to_string(),
1942                    used_fallback: false,
1943                    exists: true,
1944                },
1945                ResolvedPath {
1946                    path: "/mnt/d/workspace".to_string(),
1947                    used_fallback: true,
1948                    exists: false,
1949                },
1950            ]
1951        );
1952    }
1953
1954    #[test]
1955    fn parse_path_resolution_output_keeps_spaces_in_paths() {
1956        let resolved =
1957            parse_path_resolution_output("ok ok /mnt/c/Users/me/My Documents/project\n", 1)
1958                .unwrap();
1959        assert_eq!(resolved[0].path, "/mnt/c/Users/me/My Documents/project");
1960    }
1961
1962    #[test]
1963    fn parse_path_resolution_output_rejects_wrong_line_count() {
1964        assert!(parse_path_resolution_output("ok ok /a\n", 2).is_err());
1965        assert!(parse_path_resolution_output("ok ok /a\nok ok /b\n", 1).is_err());
1966    }
1967
1968    #[test]
1969    fn parse_path_resolution_output_rejects_corrupted_lines() {
1970        assert!(parse_path_resolution_output("garbage\n", 1).is_err());
1971        assert!(parse_path_resolution_output("weird ok /a\n", 1).is_err());
1972        assert!(parse_path_resolution_output("ok weird /a\n", 1).is_err());
1973        assert!(parse_path_resolution_output("ok ok not-absolute\n", 1).is_err());
1974    }
1975}
1976
Served at tenant.openagents/omega Member data and write actions are omitted.