dsh-plugin-wsl-env 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,86 @@ were part of the record.
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.15.0] - 2026-10-27
20
+
21
+ The compatibility-matrix release: the support table and its evidence, the
22
+ container and nightly legs that keep it current, two fresh-distro first-run
23
+ fixes the run surfaced, and the interop stance in one configuration key.
24
+
25
+ ### Changed
26
+
27
+ - **WSL-310: `scripts/bootstrap-import.sh` — a rootfs tarball becomes a
28
+ plugin-ready distro in one pass.** The command automates the import traps
29
+ the compatibility-matrix run documented: ELF-header architecture detection
30
+ (walking bin/ candidates, tolerant of the SIGPIPE an early-exiting dd
31
+ causes), `wsl --import` with an idempotent already-exists branch, the OOBE
32
+ wedge unstick, `/etc/wsl.conf` with `[boot] systemd` and `[user] default`
33
+ (an imported default-root distro makes the agent run as root), per-family
34
+ package-manager install including `systemd-sysv` — docker-export tarballs
35
+ strip `/sbin/init`, which is what broke the systemd boot chain — Linger as
36
+ root, and a final doctor pass. Verified on Debian 13.5 (systemd family:
37
+ every check green after the one mandated `wsl --shutdown`) and the Alpine
38
+ minirootfs (OpenRC: the non-systemd profile per WSL-201, systemd checks
39
+ reported as the declared degradation).
40
+
41
+ ### Fixed
42
+
43
+ - **WSL-307: the confined resident's spawn preparation is now a named
44
+ self-heal checklist.** WSL-305 fixed the missing workspace root; the same
45
+ fresh-distro class has more members (no `sh`, no `base64`, no `sha256sum` —
46
+ minirootfs ships almost none of this). The confined factory's `prepare` now
47
+ runs one `sh -c` round trip whose first failing step names itself
48
+ (`workspace-root`, `interpreter`, `base64`, `sha256sum`), and the failure
49
+ reaches the caller as `spawn checklist step '<name>' failed in <distro>`
50
+ instead of a bare handshake code. The root rides as `$1`, never
51
+ interpolated into the script body. Verified end to end on Debian with an
52
+ absent workspace root: the checklist creates it and the first confined
53
+ write lands.
54
+
55
+ ### Fixed
56
+
57
+ - **WSL-306: a directory name containing a newline lists as one exact row in
58
+ the picker.** The picker's distro-side listing was `ls -1ALp` line-parsed —
59
+ a newline in a name split into two rows or dropped out entirely. The
60
+ listing is now `find -L <dir> -maxdepth 1 -mindepth 1 -type d -print0`
61
+ (NUL-separated, `-type d` filtering in the distro where `-L` resolves
62
+ symlinks), sorted host-side. Busybox find supports every flag — verified on
63
+ alpine:3, debian:stable-slim and fedora:latest with a newline-named
64
+ fixture, and the picker probe now carries that fixture against the real
65
+ composition. The LIMITATIONS entry retires.
66
+
67
+ ### Changed
68
+
69
+ - **The doctor gains a cross-distro sessions view (WSL-309) and marks live
70
+ residents (WSL-313's observable half).** A new survey after the runtime
71
+ checks lists every running distro with its `dsh-wsl-*.scope`, the scope
72
+ state and the resident pids the distro's own cgroup attests — the
73
+ concurrent-sessions coordinate routing made possible, made visible. The
74
+ agent.handshake check now notes when a live resident is already running in
75
+ the surveyed distro (and that its fresh probe cannot read that resident's
76
+ last error). The scope report records resident pids alongside the stale
77
+ verdict. Idle countdowns and lastError stay host-side — no IPC exists, and
78
+ the boundary is documented in LIMITATIONS.
79
+
80
+ - **The real-machine probes now run nightly on the self-hosted runner.** A
81
+ schedule trigger (21:30 UTC) fires only the `probes` job — every hosted job
82
+ skips the event — so the Tier 1 "probe suite 100%, nightly" metric collects
83
+ its evidence on its own instead of waiting for a manual dispatch. A
84
+ dedicated concurrency group folds a backlog of missed nights (runner
85
+ offline) into the newest run. The probes still require the harness app to
86
+ be closed; a night where it was open reports honestly red.
87
+
88
+ - **The shell agent now runs in CI inside the non-Ubuntu containers it
89
+ claims to support.** `test/agent-container.mjs` drives the real
90
+ `wsl-agent.sh` over the v5 frames inside `docker run` for
91
+ `debian:stable-slim`, `alpine:3`, `fedora:latest`, `opensuse/tumbleweed`
92
+ and `archlinux` — seven checks each: the HELLO handshake with the
93
+ in-place script digest, PING, EXEC with exit code and both streams, SETENV
94
+ reaching later commands, and the presence of `sha256sum`/`base64` where
95
+ the families actually differ (busybox on Alpine, coreutils elsewhere). The
96
+ `agent-containers` CI job runs one container per matrix leg. 35/35 on
97
+ first execution; this closes the container leg of WSL-202's acceptance.
98
+
19
99
  ## [0.14.0] - 2026-10-27
20
100
 
21
101
  This release fixes the first-run trap the compatibility-matrix run found on a
@@ -29,6 +29,7 @@ has its own table at the end of this page.
29
29
  | | `sessionSandbox` | `true` *(shipped)* | `true`, `false`, or `auto`. With `true`, run this session's commands in one persistent systemd user scope (ADR 0001): a background dev server survives the command that started it, `/tmp` is the distro's real `/tmp` under `workspace-write`, and `ps` sees the session. The executor holds the runtime for the session and stops the scope from the preset's disposer, which reaps the cgroup. Requires `agent: true`, systemd as PID 1, a reachable user manager and `Linger` (`loginctl enable-linger <user>`); the preflight refuses with the remedy when systemd, the user manager, `systemd-run` or `Linger` is missing (a success is cached; a failure is re-probed, so following the remedy needs no app restart). `auto` uses the scope when those prerequisites answer, and otherwise falls back to the per-command `bwrap` profile — still confined, never unconfined — reporting `sessionScope: "per-command"` in the result (`"session"` when the scope ran); a failed `auto` verdict is re-probed at most once a minute. `false` is the per-command shape. On in the shipped preset |
30
30
  | | `agentBackend` | `auto` | the session runtime's in-distro agent. `auto` uses the native agent when a verified source is available — the packaged per-arch static-musl binary at `agent/native/linux-<arch>/dsh-wsl-agent` (x86_64 and aarch64; runs on glibc and musl distros), or the `DSH_WSL_AGENT_NATIVE` development override — and otherwise the shipped shell agent; `native` fails the first command when it cannot prepare instead of silently falling back; `shell` forces the shell agent. Confined writes share the runtime, so they follow the same selection. The native backend is the default (static musl x86_64/aarch64); the shell agent is the fallback, and `native` refuses instead of falling back. This key covers the session runtime (commands and confined writes) and, since A7, the shared read agent (unconfined reads), which also follows this selection with the shell fallback |
31
31
  | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox. Needs a usable `bwrap` inside the distro and fails closed without one (`SANDBOX_UNAVAILABLE`, never an unconfined run) — see [Turning the sandbox off](#turning-the-sandbox-off) |
32
+ | | `interopVisibility` | `visible` | the stance in one key (WSL-314): `visible` / `masked` / `disabled` — see the stance table below. Set at all, the stance takes precedence over the legacy `maskWindowsDrive` boolean (unset lets it answer); `disabled` masks in-session and the doctor's remedy prints the distro-level `[interop] line for the full closure (a config key does not write distro config) |
32
33
  | | `maskWindowsDrive` | `false` | shadow `/mnt` with an empty tmpfs in every confined profile: the Windows drive's files cannot be read or exfiltrated and its executables cannot be launched through interop — the hole that lets a "sandboxed" command reach your full Windows token. A narrowing, not a closure (a command can still write an executable into the workspace and run it — binfmt dispatches on content, not location), so enforcement stays `partial` and the result's sandbox fact reports `windowsDrive: "masked"`. The complete closure is distro-level: `[interop] enabled=false` in `wsl.conf` |
33
34
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
34
35
  | | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
@@ -214,6 +214,17 @@ crosses the 9p share. What to know about the one substrate there is:
214
214
  the user scopes once; a scope created or stopped a moment later is not
215
215
  reflected. `--fix` runs the same sweep the session preflight uses.
216
216
 
217
+ ## The doctor's sessions table
218
+
219
+ - **The sessions table shows distro-side facts only.** The cross-distro view
220
+ (`distro → scope → state → resident pids`) attests what the distro can see:
221
+ which scopes exist and which pids inside them are agent processes. A
222
+ resident's idle countdown and its `lastError` live in the host process that
223
+ owns it — there is no IPC channel from a plugin script to the app, so the
224
+ table cannot show them (`WSL-313`'s upstream ask). The agent.handshake
225
+ check notes a live resident's presence when the scope survey sees one, and
226
+ says that its own probe starts a fresh one.
227
+
217
228
  ## Session-start advisories
218
229
 
219
230
  - **There is no session-UI notice seam in DSH 0.2.1.** When a distro session
@@ -246,15 +257,12 @@ crosses the 9p share. What to know about the one substrate there is:
246
257
  ## The directory picker
247
258
 
248
259
  - The directory picker lists distro levels from inside the distro — the
249
- resident's `ls -1ALp` in one round trip, a one-shot `wsl.exe --exec ls` when
250
- it is out, never the 9p walk the host used to pay per entry. The listing is
251
- line-parsed and only a line that ends in `/` becomes a row, so a directory
252
- name containing a newline cannot be listed correctly: `ls` emits `a\nb` as
253
- the two lines `a` and `b/`, and the picker offers the single row `b` at
254
- `<parent>/b` — the part after the newline, a different directory or none at
255
- all. A name that ends in a newline yields no row at all: the split leaves one
256
- segment without a trailing `/` and one empty name, and the parser keeps
257
- neither. `/` cannot appear in a name, so nothing else can split one.
260
+ resident's `find -L <dir> -maxdepth 1 -mindepth 1 -type d -print0` in one
261
+ round trip, a one-shot `wsl.exe --exec find` when it is out, never the 9p
262
+ walk the host used to pay per entry. Entries arrive NUL-separated, so a
263
+ directory name containing a newline lists as one exact row (WSL-306 retired
264
+ the `ls -1ALp` line parse that used to split such a name into two rows or
265
+ drop it).
258
266
 
259
267
  ## Terminals
260
268
 
@@ -37,6 +37,17 @@ or an UNC path directly. The session's commands, file tools, terminal and previe
37
37
  all run in that distro; a Windows folder keeps the normal Windows environment.
38
38
  Two distros can be open at once, each with its own environment.
39
39
 
40
+ ![The directory picker listing a WSL distro's folders](screenshots/picker.png)
41
+
42
+ The terminal panel runs in the same distro — `uname -a` answers with the
43
+ distro's kernel, not Windows':
44
+
45
+ ![The GUI terminal running inside the distro](screenshots/terminal.png)
46
+
47
+ And the Sidebar's preview renders WSL files in place:
48
+
49
+ ![The GUI preview rendering a WSL file](screenshots/preview.png)
50
+
40
51
  ## 4. Verify
41
52
 
42
53
  Ask for a command, e.g. `pwd && uname -a`. Expected: the Linux path and the
@@ -44,6 +55,13 @@ distro kernel. If the first command fails, the error names the remedy; the
44
55
  [doctor](CONFIGURATION.md) and the [Troubleshooting table](../README.md#troubleshooting)
45
56
  cover the common cases.
46
57
 
58
+ The sandbox is enforced by the kernel, not a promise: the probe below runs the
59
+ shipped bwrap profiles live — writes inside the workspace land, writes outside
60
+ are refused with `EROFS`, and the strict profile hides `/mnt` entirely
61
+ ([`test/probe/sandbox.sh`](../test/probe/sandbox.sh)):
62
+
63
+ ![The sandbox probe: every expectation holds, the kernel refuses what the profile does not grant](screenshots/sandbox.png)
64
+
47
65
  ## Recipes
48
66
 
49
67
  Commands run in the model's session, inside the distro, from the workspace root.
@@ -1,35 +1,41 @@
1
1
  import { WslAgent } from "./agent.js";
2
2
  /**
3
- * The spawn function `ensureWorkspaceRoot` uses; injectable so a unit test can
4
- * observe the argv without a real `wsl.exe`.
3
+ * The resident's spawn checklist, as one `sh -c` body run in the distro.
4
+ *
5
+ * Steps, in order — the first failing step names itself on stderr and exits 1:
6
+ *
7
+ * - `workspace-root`: the bwrap profile binds the root (`--bind <root>
8
+ * <root>`) and bwrap refuses a missing bind source, so `mkdir -p` it — on a
9
+ * freshly imported distro the first write's workspace may not exist yet
10
+ * (WSL-305).
11
+ * - `interpreter`: the resident runs as `sh <script>`; a distro without `sh`
12
+ * is not a distro this plugin can serve.
13
+ * - `base64` and `sha256sum`: the agent script's frames and its HELLO digest
14
+ * read through them; coreutils provides both on the glibc families and
15
+ * busybox applets on Alpine (verified in the container matrix).
16
+ *
17
+ * The root rides as `$1`, so the body carries no interpolated path — the
18
+ * same body is safe for any (distro, root) pair.
19
+ *
20
+ * @param root - the Linux-spelled workspace root.
21
+ * @returns the `sh -c` body.
5
22
  */
6
- type SpawnLike = (file: string, args: string[], options: {
7
- stdio: "ignore";
8
- windowsHide: boolean;
9
- env: NodeJS.ProcessEnv;
10
- }) => {
11
- on: (event: string, handler: (...args: unknown[]) => void) => void;
12
- };
23
+ export declare function spawnChecklistScript(root: string): string;
13
24
  /**
14
- * Make sure the workspace root exists in the distro before the confined
15
- * resident's first spawn: the bwrap profile binds the root
16
- * (`--bind <root> <root>`) and bwrap refuses a missing bind source — on a
17
- * freshly imported distro the first write's workspace may not exist yet, and
18
- * the resident would exit during its handshake with a bare code 1 and no
19
- * hint (WSL-305). Idempotent (`mkdir -p`), and its outcome is deliberately
20
- * ignored here: bwrap remains the authority on whether the profile can be
21
- * built, and its refusal now reaches the caller through the handshake
22
- * error's stderr tail.
25
+ * Run the spawn checklist in the distro: one `wsl.exe --exec sh -c` round
26
+ * trip, the workspace root as `$1`. Resolves when every step passes;
27
+ * rejects with the named failing step when one does not — the resident
28
+ * would exit during its handshake with a bare code 1 and no hint (WSL-305),
29
+ * and a name beats a code.
23
30
  *
24
- * @param options - the transport, distro, root, and an injectable spawn.
25
- * @returns resolves once the `mkdir -p` has been issued (settled, whatever
26
- * its result — bwrap decides).
31
+ * @param options - the transport, distro, root, and an injectable run.
32
+ * @returns resolves when the checklist passes.
27
33
  */
28
- export declare function ensureWorkspaceRoot({ wslPath, distro, root, spawnFn }: {
34
+ export declare function runSpawnChecklist({ wslPath, distro, root, runFn }: {
29
35
  wslPath: string;
30
36
  distro: string;
31
37
  root: string;
32
- spawnFn?: SpawnLike;
38
+ runFn?: (argv: string[]) => Promise<unknown>;
33
39
  }): Promise<void>;
34
40
  /** One confinement request. */
35
41
  export interface ConfinedAgentRequest {
@@ -31,38 +31,71 @@
31
31
  *
32
32
  * @module dsh-plugin-wsl/agent-confined
33
33
  */
34
- import { spawn } from "node:child_process";
35
34
  import { bwrapArgvPrefix } from "./bwrap.js";
36
35
  import { toLinuxPath } from "./paths.js";
37
36
  import { agentScriptDigest, agentScriptPath } from "./agent-shared.js";
38
- import { pinnedWindowsEnv, WslAgent } from "./agent.js";
37
+ import { WslAgent } from "./agent.js";
39
38
  const confinedAgents = new Map();
40
39
  /**
41
- * Make sure the workspace root exists in the distro before the confined
42
- * resident's first spawn: the bwrap profile binds the root
43
- * (`--bind <root> <root>`) and bwrap refuses a missing bind source — on a
44
- * freshly imported distro the first write's workspace may not exist yet, and
45
- * the resident would exit during its handshake with a bare code 1 and no
46
- * hint (WSL-305). Idempotent (`mkdir -p`), and its outcome is deliberately
47
- * ignored here: bwrap remains the authority on whether the profile can be
48
- * built, and its refusal now reaches the caller through the handshake
49
- * error's stderr tail.
40
+ * The resident's spawn checklist, as one `sh -c` body run in the distro.
50
41
  *
51
- * @param options - the transport, distro, root, and an injectable spawn.
52
- * @returns resolves once the `mkdir -p` has been issued (settled, whatever
53
- * its result — bwrap decides).
42
+ * Steps, in order — the first failing step names itself on stderr and exits 1:
43
+ *
44
+ * - `workspace-root`: the bwrap profile binds the root (`--bind <root>
45
+ * <root>`) and bwrap refuses a missing bind source, so `mkdir -p` it — on a
46
+ * freshly imported distro the first write's workspace may not exist yet
47
+ * (WSL-305).
48
+ * - `interpreter`: the resident runs as `sh <script>`; a distro without `sh`
49
+ * is not a distro this plugin can serve.
50
+ * - `base64` and `sha256sum`: the agent script's frames and its HELLO digest
51
+ * read through them; coreutils provides both on the glibc families and
52
+ * busybox applets on Alpine (verified in the container matrix).
53
+ *
54
+ * The root rides as `$1`, so the body carries no interpolated path — the
55
+ * same body is safe for any (distro, root) pair.
56
+ *
57
+ * @param root - the Linux-spelled workspace root.
58
+ * @returns the `sh -c` body.
54
59
  */
55
- export function ensureWorkspaceRoot({ wslPath, distro, root, spawnFn }) {
56
- return new Promise((resolve) => {
57
- const doSpawn = spawnFn ?? ((file, args, options) => spawn(file, args, options));
58
- const child = doSpawn(wslPath, ["-d", distro, "--exec", "mkdir", "-p", root], {
59
- stdio: "ignore",
60
- windowsHide: true,
61
- env: pinnedWindowsEnv(process.env),
62
- });
63
- child.on("error", () => resolve());
64
- child.on("exit", () => resolve());
60
+ export function spawnChecklistScript(root) {
61
+ return [
62
+ 'mkdir -p -- "$1" || { echo workspace-root >&2; exit 1; }',
63
+ "command -v sh >/dev/null || { echo interpreter >&2; exit 1; }",
64
+ "command -v base64 >/dev/null || { echo base64 >&2; exit 1; }",
65
+ "command -v sha256sum >/dev/null || { echo sha256sum >&2; exit 1; }",
66
+ ].join("\n");
67
+ }
68
+ /**
69
+ * Run the spawn checklist in the distro: one `wsl.exe --exec sh -c` round
70
+ * trip, the workspace root as `$1`. Resolves when every step passes;
71
+ * rejects with the named failing step when one does not — the resident
72
+ * would exit during its handshake with a bare code 1 and no hint (WSL-305),
73
+ * and a name beats a code.
74
+ *
75
+ * @param options - the transport, distro, root, and an injectable run.
76
+ * @returns resolves when the checklist passes.
77
+ */
78
+ export async function runSpawnChecklist({ wslPath, distro, root, runFn }) {
79
+ const run = runFn ?? (async (argv) => {
80
+ const { runCapture } = await import("./wsl.js");
81
+ return runCapture([wslPath, "-d", distro, ...argv]);
65
82
  });
83
+ let detail = "";
84
+ try {
85
+ await run(["--exec", "sh", "-c", spawnChecklistScript(root), "sh", root]);
86
+ }
87
+ catch (error) {
88
+ // runCapture rejects on the checklist's non-zero exit with the stderr
89
+ // folded into the message — where the failing step named itself.
90
+ detail = String(error).trim();
91
+ }
92
+ const named = /workspace-root|interpreter|base64|sha256sum/.exec(detail)?.[0];
93
+ if (named !== undefined) {
94
+ throw new Error(`spawn checklist step '${named}' failed in ${distro}`);
95
+ }
96
+ if (detail !== "") {
97
+ throw new Error(`spawn checklist could not run in ${distro}: ${detail.slice(0, 200)}`);
98
+ }
66
99
  }
67
100
  /**
68
101
  * The confined agent for one distro and one resolved policy, created on first
@@ -94,7 +127,7 @@ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindows
94
127
  // used to exit during its handshake with a bare code 1. Create the root
95
128
  // before the first spawn; every later start re-runs it harmlessly.
96
129
  prepare: policy.mode === "workspace-write"
97
- ? () => ensureWorkspaceRoot({ wslPath, distro, root: linuxRoot }).then(() => undefined)
130
+ ? () => runSpawnChecklist({ wslPath, distro, root: linuxRoot }).then(() => undefined)
98
131
  : undefined,
99
132
  });
100
133
  confinedAgents.set(key, agent);
package/lib/index.d.ts CHANGED
@@ -83,6 +83,21 @@ export type WslExecSpec = ShellExecSpec & {
83
83
  * wrappers, so they are spelled as their `.get()` shape (the reading
84
84
  * {@link configuredCwd} and `hostCwd` perform).
85
85
  */
86
+ /**
87
+ * The effective `/mnt` mask for a config: `interopVisibility` is the stance
88
+ * key (WSL-314) and takes precedence whenever it names a stance; the legacy
89
+ * `maskWindowsDrive` boolean answers only when the stance key is left at its
90
+ * `visible` default. `disabled` masks in-session — the belt — while the
91
+ * distro-level closure (`[interop] enabled=false` + `wsl --shutdown`)
92
+ * remains a documented, user-applied step a config key must not perform.
93
+ *
94
+ * @param config - anything carrying the two keys.
95
+ * @returns whether confined profiles shadow `/mnt`.
96
+ */
97
+ export declare function effectiveMaskWindowsDrive(config: {
98
+ interopVisibility?: "visible" | "masked" | "disabled";
99
+ maskWindowsDrive?: boolean;
100
+ }): boolean;
86
101
  export interface WslShellExecutorConfig {
87
102
  /** The pinned distro; empty means WSL's default. */
88
103
  distro: string;
@@ -96,6 +111,8 @@ export interface WslShellExecutorConfig {
96
111
  sandbox: boolean;
97
112
  /** Whether confined profiles mask `/mnt`. */
98
113
  maskWindowsDrive: boolean;
114
+ /** The interop stance; set at all, it takes precedence over `maskWindowsDrive`. */
115
+ interopVisibility?: "visible" | "masked" | "disabled";
99
116
  /** The volatile default workdir. */
100
117
  cwd: {
101
118
  get(): string | undefined;
@@ -218,6 +235,26 @@ export declare class WslShellExecutor extends LocalBashExecutor {
218
235
  * complete closure is distro-level: `[interop] enabled=false` in
219
236
  * `wsl.conf`.
220
237
  */
238
+ /**
239
+ * The interop stance in one key (WSL-314), expanding to the per-key
240
+ * values the measured matrix documents:
241
+ *
242
+ * - `visible` (default) — nothing masked; a confined command can read
243
+ * `/mnt` and launch Windows executables through interop, which escapes
244
+ * the Linux sandbox entirely.
245
+ * - `masked` — `maskWindowsDrive: true`: `/mnt` is shadowed with an empty
246
+ * tmpfs; the drive's files and executables are unreachable (binfmt can
247
+ * still run a workspace-written .exe, so enforcement stays `partial`).
248
+ * - `disabled` — `masked`, plus the distro-level closure: `[interop]
249
+ * enabled=false` in `/etc/wsl.conf` + `wsl --shutdown`, which the
250
+ * doctor's remedy prints as copy-paste (a config key does not write
251
+ * distro config; enforcement is the kernel's, not the schema's).
252
+ *
253
+ * When set at all it takes precedence over the legacy
254
+ * `maskWindowsDrive` boolean below (unset — the default — lets that
255
+ * boolean answer); the boolean remains for existing profiles.
256
+ */
257
+ interopVisibility: z<"masked" | "visible" | "disabled", "masked" | "visible" | "disabled", "plain">;
221
258
  maskWindowsDrive: z<boolean, boolean, "defined">;
222
259
  /**
223
260
  * Default workdir for a request that names none; accepts `/home/andy/proj`,
@@ -296,6 +333,26 @@ export declare class WslShellExecutor extends LocalBashExecutor {
296
333
  * complete closure is distro-level: `[interop] enabled=false` in
297
334
  * `wsl.conf`.
298
335
  */
336
+ /**
337
+ * The interop stance in one key (WSL-314), expanding to the per-key
338
+ * values the measured matrix documents:
339
+ *
340
+ * - `visible` (default) — nothing masked; a confined command can read
341
+ * `/mnt` and launch Windows executables through interop, which escapes
342
+ * the Linux sandbox entirely.
343
+ * - `masked` — `maskWindowsDrive: true`: `/mnt` is shadowed with an empty
344
+ * tmpfs; the drive's files and executables are unreachable (binfmt can
345
+ * still run a workspace-written .exe, so enforcement stays `partial`).
346
+ * - `disabled` — `masked`, plus the distro-level closure: `[interop]
347
+ * enabled=false` in `/etc/wsl.conf` + `wsl --shutdown`, which the
348
+ * doctor's remedy prints as copy-paste (a config key does not write
349
+ * distro config; enforcement is the kernel's, not the schema's).
350
+ *
351
+ * When set at all it takes precedence over the legacy
352
+ * `maskWindowsDrive` boolean below (unset — the default — lets that
353
+ * boolean answer); the boolean remains for existing profiles.
354
+ */
355
+ interopVisibility: z<"masked" | "visible" | "disabled", "masked" | "visible" | "disabled", "plain">;
299
356
  maskWindowsDrive: z<boolean, boolean, "defined">;
300
357
  /**
301
358
  * Default workdir for a request that names none; accepts `/home/andy/proj`,
package/lib/index.js CHANGED
@@ -159,6 +159,29 @@ function wslWritableRoots(policy, distro) {
159
159
  }
160
160
  return [...roots];
161
161
  }
162
+ /**
163
+ * The WSL shell executor's validated config — the keys of
164
+ * {@link WslShellExecutor.Config}, which the upstream `LocalBashExecutor`
165
+ * config type does not know. The seven volatile fields arrive as live
166
+ * wrappers, so they are spelled as their `.get()` shape (the reading
167
+ * {@link configuredCwd} and `hostCwd` perform).
168
+ */
169
+ /**
170
+ * The effective `/mnt` mask for a config: `interopVisibility` is the stance
171
+ * key (WSL-314) and takes precedence whenever it names a stance; the legacy
172
+ * `maskWindowsDrive` boolean answers only when the stance key is left at its
173
+ * `visible` default. `disabled` masks in-session — the belt — while the
174
+ * distro-level closure (`[interop] enabled=false` + `wsl --shutdown`)
175
+ * remains a documented, user-applied step a config key must not perform.
176
+ *
177
+ * @param config - anything carrying the two keys.
178
+ * @returns whether confined profiles shadow `/mnt`.
179
+ */
180
+ export function effectiveMaskWindowsDrive(config) {
181
+ if (config.interopVisibility !== undefined)
182
+ return config.interopVisibility !== "visible";
183
+ return config.maskWindowsDrive === true;
184
+ }
162
185
  const inheritedSpawnSpec = LocalBashExecutor.prototype.spawnSpec;
163
186
  /**
164
187
  * How long an `auto` distro that could not host a session runtime stays
@@ -257,6 +280,26 @@ export class WslShellExecutor extends LocalBashExecutor {
257
280
  * complete closure is distro-level: `[interop] enabled=false` in
258
281
  * `wsl.conf`.
259
282
  */
283
+ /**
284
+ * The interop stance in one key (WSL-314), expanding to the per-key
285
+ * values the measured matrix documents:
286
+ *
287
+ * - `visible` (default) — nothing masked; a confined command can read
288
+ * `/mnt` and launch Windows executables through interop, which escapes
289
+ * the Linux sandbox entirely.
290
+ * - `masked` — `maskWindowsDrive: true`: `/mnt` is shadowed with an empty
291
+ * tmpfs; the drive's files and executables are unreachable (binfmt can
292
+ * still run a workspace-written .exe, so enforcement stays `partial`).
293
+ * - `disabled` — `masked`, plus the distro-level closure: `[interop]
294
+ * enabled=false` in `/etc/wsl.conf` + `wsl --shutdown`, which the
295
+ * doctor's remedy prints as copy-paste (a config key does not write
296
+ * distro config; enforcement is the kernel's, not the schema's).
297
+ *
298
+ * When set at all it takes precedence over the legacy
299
+ * `maskWindowsDrive` boolean below (unset — the default — lets that
300
+ * boolean answer); the boolean remains for existing profiles.
301
+ */
302
+ interopVisibility: z.union(["visible", "masked", "disabled"]),
260
303
  maskWindowsDrive: z.boolean().default(false),
261
304
  /**
262
305
  * Default workdir for a request that names none; accepts `/home/andy/proj`,
@@ -307,7 +350,7 @@ export class WslShellExecutor extends LocalBashExecutor {
307
350
  this.resolvedHomes = new Map();
308
351
  this.mode = config.sandbox ? ctx.sandboxPolicy.defaultMode : undefined;
309
352
  this.sandbox = config.sandbox
310
- ? new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive })
353
+ ? new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: effectiveMaskWindowsDrive(config) })
311
354
  : undefined;
312
355
  this.processFacts = new Map();
313
356
  this.sessionRuntimes = sessionRuntimes;
@@ -456,7 +499,7 @@ export class WslShellExecutor extends LocalBashExecutor {
456
499
  return undefined;
457
500
  const mode = policy?.mode ?? "danger-full-access";
458
501
  const workspaceRoot = policy?.workspaceRoot ?? (configuredCwd(this.config) || resolved.workdir);
459
- const key = SessionRuntimes.key({ distro, workspaceRoot, mode, maskWindowsDrive: config.maskWindowsDrive });
502
+ const key = SessionRuntimes.key({ distro, workspaceRoot, mode, maskWindowsDrive: effectiveMaskWindowsDrive(config) });
460
503
  const held = this.sessionRuntimeEntries.get(key);
461
504
  if (held !== undefined)
462
505
  return held;
@@ -465,7 +508,7 @@ export class WslShellExecutor extends LocalBashExecutor {
465
508
  wslPath: config.wslPath,
466
509
  workspaceRoot,
467
510
  mode,
468
- maskWindowsDrive: config.maskWindowsDrive,
511
+ maskWindowsDrive: effectiveMaskWindowsDrive(config),
469
512
  });
470
513
  this.sessionRuntimeEntries.set(key, entry);
471
514
  return entry;
package/lib/listing.d.ts CHANGED
@@ -83,32 +83,39 @@ export declare function boundedInsert<T extends {
83
83
  /**
84
84
  * The distro-side argv one picker level is listed with.
85
85
  *
86
- * `-1` is one entry per line, `-A` keeps dot-entries but drops `.` and `..`,
87
86
  * `-L` follows symlinks so a link to a directory reports as a directory and a
88
87
  * broken link drops out (exactly what the per-entry `stat` the host path used
89
- * to pay one 9p round trip for), and `-p` marks directories with the trailing
90
- * `/` the parser keys on.
88
+ * to pay one 9p round trip for); `-maxdepth 1` keeps the listing to one level;
89
+ * `-mindepth 1` drops the start point the way `ls -A` dropped `.` and `..`;
90
+ * `-type d` keeps only enterable rows; `-print0` separates entries with NUL —
91
+ * a filename containing a newline arrives whole instead of splitting into two
92
+ * rows, which is the bug that retired the `ls -1ALp` line parse (WSL-306).
93
+ * `find` is unsorted; the parser sorts.
94
+ *
95
+ * Busybox find (Alpine) supports every flag here — verified on alpine:3,
96
+ * debian:stable-slim and fedora:latest with a newline-named fixture.
91
97
  *
92
98
  * @param linuxDir - the absolute Linux directory to list.
93
99
  * @returns the argv for the agent or a `wsl.exe --exec` launch.
94
100
  */
95
- export declare function lsListingArgv(linuxDir: string): string[];
101
+ export declare function dirListingArgv(linuxDir: string): string[];
96
102
  /**
97
- * Parse one `ls -1ALp` listing into the picker's rows.
103
+ * Parse one `find -print0` listing into the picker's rows.
98
104
  *
99
- * A line is a row when it ends with `/` — a directory, or a symlink `-L`
100
- * resolved to one; files and dangling links are skipped, matching the host
101
- * path's behaviour. The parse reads the listing as UTF-8 lines, so a filename
102
- * containing a newline would split into two rows — the same limitation class
103
- * the one-shot transport has always had, accepted here with the 9p walk it
104
- * replaces.
105
+ * Entries arrive NUL-separated as `<linuxDir>/<name>`; the linuxDir prefix is
106
+ * stripped to recover the bare name (the root listing's `/name` form
107
+ * included). `find` is unsorted, so the names are sorted host-side — a
108
+ * deterministic code-point order, which is what the picker shows. The first
109
+ * `maxEntries` names become rows; a level with more is flagged truncated.
105
110
  *
106
111
  * @param stdout - the listing's raw stdout.
112
+ * @param linuxDir - the absolute Linux directory that was listed (the argv's
113
+ * own spelling — the prefix stripped from every entry).
107
114
  * @param parent - the listed directory, in the UNC form the rows carry.
108
115
  * @param maxEntries - the row bound; a level with more is flagged truncated.
109
116
  * @returns the first `maxEntries` rows and whether the level had more.
110
117
  */
111
- export declare function parseLsListing(stdout: string | Buffer, parent: string, maxEntries: number): {
118
+ export declare function parseDirListing(stdout: string | Buffer, linuxDir: string, parent: string, maxEntries: number): {
112
119
  rows: ListingRow[];
113
120
  truncated: boolean;
114
121
  };
package/lib/listing.js CHANGED
@@ -126,42 +126,57 @@ export function boundedInsert(window, candidate, keep) {
126
126
  /**
127
127
  * The distro-side argv one picker level is listed with.
128
128
  *
129
- * `-1` is one entry per line, `-A` keeps dot-entries but drops `.` and `..`,
130
129
  * `-L` follows symlinks so a link to a directory reports as a directory and a
131
130
  * broken link drops out (exactly what the per-entry `stat` the host path used
132
- * to pay one 9p round trip for), and `-p` marks directories with the trailing
133
- * `/` the parser keys on.
131
+ * to pay one 9p round trip for); `-maxdepth 1` keeps the listing to one level;
132
+ * `-mindepth 1` drops the start point the way `ls -A` dropped `.` and `..`;
133
+ * `-type d` keeps only enterable rows; `-print0` separates entries with NUL —
134
+ * a filename containing a newline arrives whole instead of splitting into two
135
+ * rows, which is the bug that retired the `ls -1ALp` line parse (WSL-306).
136
+ * `find` is unsorted; the parser sorts.
137
+ *
138
+ * Busybox find (Alpine) supports every flag here — verified on alpine:3,
139
+ * debian:stable-slim and fedora:latest with a newline-named fixture.
134
140
  *
135
141
  * @param linuxDir - the absolute Linux directory to list.
136
142
  * @returns the argv for the agent or a `wsl.exe --exec` launch.
137
143
  */
138
- export function lsListingArgv(linuxDir) {
139
- return ["ls", "-1ALp", "--", linuxDir];
144
+ export function dirListingArgv(linuxDir) {
145
+ return ["find", "-L", linuxDir, "-maxdepth", "1", "-mindepth", "1", "-type", "d", "-print0"];
140
146
  }
141
147
  /**
142
- * Parse one `ls -1ALp` listing into the picker's rows.
148
+ * Parse one `find -print0` listing into the picker's rows.
143
149
  *
144
- * A line is a row when it ends with `/` — a directory, or a symlink `-L`
145
- * resolved to one; files and dangling links are skipped, matching the host
146
- * path's behaviour. The parse reads the listing as UTF-8 lines, so a filename
147
- * containing a newline would split into two rows — the same limitation class
148
- * the one-shot transport has always had, accepted here with the 9p walk it
149
- * replaces.
150
+ * Entries arrive NUL-separated as `<linuxDir>/<name>`; the linuxDir prefix is
151
+ * stripped to recover the bare name (the root listing's `/name` form
152
+ * included). `find` is unsorted, so the names are sorted host-side — a
153
+ * deterministic code-point order, which is what the picker shows. The first
154
+ * `maxEntries` names become rows; a level with more is flagged truncated.
150
155
  *
151
156
  * @param stdout - the listing's raw stdout.
157
+ * @param linuxDir - the absolute Linux directory that was listed (the argv's
158
+ * own spelling — the prefix stripped from every entry).
152
159
  * @param parent - the listed directory, in the UNC form the rows carry.
153
160
  * @param maxEntries - the row bound; a level with more is flagged truncated.
154
161
  * @returns the first `maxEntries` rows and whether the level had more.
155
162
  */
156
- export function parseLsListing(stdout, parent, maxEntries) {
157
- const rows = [];
158
- let truncated = false;
159
- for (const line of String(stdout).split("\n")) {
160
- if (!line.endsWith("/"))
163
+ export function parseDirListing(stdout, linuxDir, parent, maxEntries) {
164
+ const prefix = linuxDir === "/" ? "/" : `${linuxDir}/`;
165
+ const names = [];
166
+ for (const entry of String(stdout).split("\0")) {
167
+ if (entry.length === 0)
161
168
  continue;
162
- const name = line.slice(0, -1);
163
- if (name.length === 0)
169
+ const name = entry.startsWith(prefix) ? entry.slice(prefix.length) : entry;
170
+ // A defensive guard, not an expected shape: `-maxdepth 1` output is
171
+ // always `<dir>/<name>`, so a stray separator means a broken listing.
172
+ if (name.length === 0 || name.includes("/"))
164
173
  continue;
174
+ names.push(name);
175
+ }
176
+ names.sort();
177
+ const rows = [];
178
+ let truncated = false;
179
+ for (const name of names) {
165
180
  if (rows.length === maxEntries) {
166
181
  truncated = true;
167
182
  break;
package/lib/picker.js CHANGED
@@ -53,7 +53,7 @@ import { join, resolve } from "node:path";
53
53
  import z from "@deepseek-ai/schemastery";
54
54
  import { DirectoryPicker, DirectoryPickerError } from "@deepseek-ai/dsh-host-directory-picker";
55
55
  import { UNC_PROVIDER_ROOT, distroRoot, isProviderRoot, isWslUnc, uncToPosix } from "./paths.js";
56
- import { breadcrumbs, boundedInsert, fullyQualified, lsListingArgv, parseLsListing } from "./listing.js";
56
+ import { breadcrumbs, boundedInsert, dirListingArgv, fullyQualified, parseDirListing } from "./listing.js";
57
57
  import { listDistros, linuxHome, runCapture } from "./wsl.js";
58
58
  import { sharedAgent } from "./agent-shared.js";
59
59
  import { AgentUnavailableError } from "./agent-errors.js";
@@ -321,7 +321,7 @@ export class WslDirectoryPicker extends DirectoryPicker {
321
321
  try {
322
322
  const result = await sharedAgent(parsed.distro).exec({
323
323
  cwd: linuxDir,
324
- argv: lsListingArgv(linuxDir),
324
+ argv: dirListingArgv(linuxDir),
325
325
  timeoutMs: 15_000,
326
326
  signal,
327
327
  });
@@ -335,13 +335,13 @@ export class WslDirectoryPicker extends DirectoryPicker {
335
335
  if (!(error instanceof AgentUnavailableError))
336
336
  throw error;
337
337
  try {
338
- stdout = await runCapture([this.config.wslPath, "-d", parsed.distro, "--cd", linuxDir, "--exec", ...lsListingArgv(linuxDir)], signal);
338
+ stdout = await runCapture([this.config.wslPath, "-d", parsed.distro, "--cd", linuxDir, "--exec", ...dirListingArgv(linuxDir)], signal);
339
339
  }
340
340
  catch (fallbackError) {
341
341
  throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(fallbackError)}`);
342
342
  }
343
343
  }
344
- return parseLsListing(stdout, target, this.config.maxEntries);
344
+ return parseDirListing(stdout, linuxDir, target, this.config.maxEntries);
345
345
  }
346
346
  /**
347
347
  * List one Windows level with the host's own walk — native I/O on a native
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -36,6 +36,7 @@ import { fileURLToPath } from "node:url";
36
36
  import { parseDistroList } from "../lib/wsl.js";
37
37
  import { AGENT_NAME, PROTOCOL_VERSION } from "../lib/agent-protocol.js";
38
38
  import { sessionScopeSweepScript } from "../lib/session-scope.js";
39
+ import { parseListeningPorts, procNetScript } from "../lib/ports.js";
39
40
 
40
41
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
41
42
  const WSL = "wsl.exe";
@@ -63,10 +64,10 @@ const STALE_SCOPE_REPORT = [
63
64
  ' if [ -n "$cg" ] && [ -r "/sys/fs/cgroup$cg/cgroup.procs" ]; then',
64
65
  ' while read -r pid; do',
65
66
  ' [ -n "$pid" ] || continue',
66
- " if tr '\\0' ' ' < \"/proc/$pid/cmdline\" 2>/dev/null | grep -q -e 'wsl-agent.sh' -e 'dsh-wsl-agent'; then alive=1; break; fi",
67
+ " if tr '\\0' ' ' < \"/proc/$pid/cmdline\" 2>/dev/null | grep -q -e 'wsl-agent.sh' -e 'dsh-wsl-agent'; then alive=1; pids=\"$pids $pid\"; fi",
67
68
  ' done < "/sys/fs/cgroup$cg/cgroup.procs"',
68
69
  " fi",
69
- ' echo "$unit $([ "$alive" -eq 1 ] && echo active || echo stale)"',
70
+ ' echo "$unit $([ "$alive" -eq 1 ] && echo active || echo stale) pids:${pids:-none}"',
70
71
  "done",
71
72
  ].join("\n");
72
73
  /** The agent-cache inventory: one `state digest` line per cached binary. */
@@ -385,6 +386,14 @@ async function main(options) {
385
386
  check("agent.cache", bad > 0 ? "warn" : "ok", "Agent cache", `${entries.length} cached binary(ies)${bad > 0 ? `, ${bad} not executable` : ""}`, bad > 0 ? `rm -rf ~/.cache/dsh-wsl/agent inside "${distro}"; the next command repopulates it` : undefined);
386
387
  }
387
388
 
389
+ const scopes = (await inDistro(distro, STALE_SCOPE_REPORT)).stdout.trim();
390
+ const scopeLines = scopes === "none" || scopes.length === 0 ? [] : scopes.split(/\r?\n/);
391
+ const stale = scopeLines.filter((line) => /\sstale(\s|$)/.test(line)).length;
392
+ const livePids = scopeLines.flatMap((line) => {
393
+ const m = /\spids:([0-9 ]+)$/.exec(line);
394
+ if (m === null) return [];
395
+ return (m[1] ?? "").trim().split(/\s+/).filter((p) => p.length > 0);
396
+ });
388
397
  // ---- resident handshake --------------------------------------------------
389
398
  const agentScript = join(ROOT, "agent", "wsl-agent.sh");
390
399
  if (!agentScript.startsWith("/")) {
@@ -394,8 +403,11 @@ async function main(options) {
394
403
  } else {
395
404
  const handshake = await probeResident(distro, agentScript);
396
405
  if (handshake.ok) {
406
+ const live = livePids.length > 0
407
+ ? `; a live resident (pid${livePids.length > 1 ? "s" : ""} ${livePids.join(", ")}) is already running in this distro — this check starts a fresh one, which cannot read its last error`
408
+ : "";
397
409
  check("agent.handshake", handshake.digestMatches ? "ok" : "fail", "Agent handshake",
398
- `pid ${handshake.pid}, protocol ${PROTOCOL_VERSION}, digest ${handshake.digestMatches ? "matches" : "MISMATCH — the distro reads a different script than this package"}`,
410
+ `pid ${handshake.pid}, protocol ${PROTOCOL_VERSION}, digest ${handshake.digestMatches ? "matches" : "MISMATCH — the distro reads a different script than this package"}${live}`,
399
411
  handshake.digestMatches ? undefined : "resync the runtime copy (pnpm run sync:windows), then restart the app");
400
412
  } else {
401
413
  check("agent.handshake", "fail", "Agent handshake", handshake.detail, "restart the DSH app; if it persists, delete ~/.cache/dsh-wsl/agent inside the distro and rerun the doctor");
@@ -403,13 +415,75 @@ async function main(options) {
403
415
  }
404
416
 
405
417
  // ---- stale session scopes ------------------------------------------------
406
- const scopes = (await inDistro(distro, STALE_SCOPE_REPORT)).stdout.trim();
407
- const scopeLines = scopes === "none" || scopes.length === 0 ? [] : scopes.split(/\r?\n/);
408
- const stale = scopeLines.filter((line) => line.endsWith(" stale")).length;
409
418
  if (scopeLines.length === 0) check("runtime.scopes", "info", "Session scopes", "none (no user manager, or none left behind)");
410
419
  else if (stale === 0) check("runtime.scopes", "ok", "Session scopes", `${scopeLines.length} live, none stale`);
411
420
  else check("runtime.scopes", "warn", "Session scopes", `${stale} stale of ${scopeLines.length}`, "--fix stops them (the same sweep the session preflight runs); they are safe to stop when no agent process is inside");
412
421
 
422
+ // ---- sessions across running distros (WSL-309) ----------------------------
423
+ // The single-distro checks above answer "is this distro healthy?"; this
424
+ // survey answers "what is running right now, everywhere?" — the concurrent
425
+ // sessions coordinate routing makes possible, made visible. Idle timers are
426
+ // the host process's private state and stay out: a scope's presence and its
427
+ // resident pids are the facts the distro side can attest.
428
+ // The distro lists both surveys share: `run` settles an object
429
+ // ({ ok, stdout, code }); `-q` prints plain names, and the running list's
430
+ // header is dropped by the intersection with them. The default distro
431
+ // carries a localized marker ("ubuntu (默认值)" / "ubuntu (default)") —
432
+ // strip a trailing parenthesized note before the intersection.
433
+ const distroNames = ((await run([WSL, "--list", "-q"])).stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
434
+ const running = ((await run([WSL, "--list", "--running"])).stdout ?? "").split(/\r?\n/).map((line) => line.trim().replace(/\s+\([^)]*\)$/, "")).filter((line) => distroNames.includes(line));
435
+ if (!options.json) {
436
+ console.log("");
437
+ console.log("sessions across running distros");
438
+ if (running.length === 0) {
439
+ console.log(" (none)");
440
+ } else {
441
+ for (const d of running) {
442
+ const report = (await inDistro(d, STALE_SCOPE_REPORT)).stdout.trim();
443
+ const lines = report === "none" || report.length === 0 ? [] : report.split(/\r?\n/);
444
+ if (report === "none") {
445
+ console.log(` ${d} no user manager — scopes unavailable`);
446
+ continue;
447
+ }
448
+ if (lines.length === 0) {
449
+ console.log(` ${d} no session scopes`);
450
+ continue;
451
+ }
452
+ for (const line of lines) {
453
+ const m = /^(\S+) (active|stale) pids:(.*)$/.exec(line);
454
+ if (m === null) { console.log(` ${d} ${line}`); continue; }
455
+ const scope = m[1] ?? line;
456
+ const state = m[2] ?? "";
457
+ const pidNote = m[3] === "none" ? "" : ` resident pids:${(m[3] ?? "").trim()}`;
458
+ console.log(` ${d} ${scope} ${state}${pidNote}`);
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ // ---- listening ports across running distros (WSL-308) ----------------------
465
+ // The port poller serves the model's session (DSH_WSL_PORTS); this survey
466
+ // serves the human, with the two actions the facts imply: the localhost URL
467
+ // WSL2's forwarding already makes reachable, and the netsh line that would
468
+ // extend reach beyond the host (printed, never applied).
469
+ if (!options.json) {
470
+ const portScript = procNetScript();
471
+ let anyPorts = false;
472
+ for (const d of running) {
473
+ const answer = await inDistro(d, portScript);
474
+ const ports = parseListeningPorts(answer.stdout);
475
+ if (ports.length === 0) continue;
476
+ anyPorts = true;
477
+ const ip = ((await inDistro(d, "hostname -I")).stdout ?? "").trim().split(/\s+/)[0] ?? "";
478
+ console.log("");
479
+ console.log(`listening ports in ${d}`);
480
+ for (const port of ports) {
481
+ console.log(` :${port} http://localhost:${port} (LAN: netsh interface portproxy add v4tov4 listenport=${port} listenaddress=0.0.0.0 connectport=${port} connectaddress=${ip})`);
482
+ }
483
+ }
484
+ if (!anyPorts && running.length > 0) console.log("\nno listening ports in the running distros");
485
+ }
486
+
413
487
  // ---- peers ---------------------------------------------------------------
414
488
  const manifest = /** @type {{ peerDependencies?: Record<string, string> }} */ (JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")));
415
489
  const peers = Object.entries(manifest.peerDependencies ?? {});