dsh-plugin-wsl-env 0.13.2 → 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 +142 -0
- package/docs/CONFIGURATION.md +1 -0
- package/docs/LIMITATIONS.md +17 -9
- package/docs/QUICKSTART.md +18 -0
- package/lib/agent-confined.d.ts +38 -0
- package/lib/agent-confined.js +71 -1
- package/lib/agent.js +9 -1
- package/lib/index.d.ts +57 -0
- package/lib/index.js +46 -3
- package/lib/listing.d.ts +19 -12
- package/lib/listing.js +34 -19
- package/lib/picker.js +4 -4
- package/package.json +1 -1
- package/scripts/doctor.mjs +80 -6
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,148 @@ 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
|
+
|
|
99
|
+
## [0.14.0] - 2026-10-27
|
|
100
|
+
|
|
101
|
+
This release fixes the first-run trap the compatibility-matrix run found on a
|
|
102
|
+
freshly imported distro, and ships the measured support table the run produced.
|
|
103
|
+
|
|
104
|
+
### Fixed
|
|
105
|
+
|
|
106
|
+
- **WSL-305: the confined resident now creates the workspace root before its
|
|
107
|
+
first spawn.** The bwrap profile binds the workspace root and bwrap refuses
|
|
108
|
+
a missing bind source, so on a freshly imported distro the first confined
|
|
109
|
+
write failed with a bare `FS_IO_ERROR: agent exited during handshake
|
|
110
|
+
(code=1)` and a misleading remedy. `agent-confined.js` gained a `prepare`
|
|
111
|
+
hook (workspace-write only — the only mode that binds the root) that runs
|
|
112
|
+
`mkdir -p` on the Linux-spelled root before bwrap runs, idempotently and
|
|
113
|
+
ignoring its own outcome: bwrap stays the authority, and a refusal still
|
|
114
|
+
reaches the caller. That refusal now carries evidence: the handshake
|
|
115
|
+
failure's message includes a bounded tail of the resident's stderr (the
|
|
116
|
+
half-day of diagnosis this cost came from it being swallowed). Three new
|
|
117
|
+
checks cover the prepare hook, its argv, and the stderr ride-on; a
|
|
118
|
+
fresh-root leg in `substrate.sh` exercises the absent-root spawn end to
|
|
119
|
+
end on a real distro.
|
|
120
|
+
|
|
121
|
+
### Changed
|
|
122
|
+
|
|
123
|
+
- **Debian is verified end to end, and the run found a first-run trap.**
|
|
124
|
+
The four harness-level probes pass against a `wslfs-debian` profile on a
|
|
125
|
+
freshly imported Debian 13.5, completing the full stack on top of the
|
|
126
|
+
distro-internal suite (`docs/COMPATIBILITY.md`). Getting there surfaced one
|
|
127
|
+
real finding: the confined resident refuses to start when the workspace
|
|
128
|
+
root does not exist yet — the bwrap profile binds the root, bwrap requires
|
|
129
|
+
the bind source to exist, and a fresh distro has nothing to bind. Creating
|
|
130
|
+
the root first turns all ten failing steps green; prior Ubuntu runs had it
|
|
131
|
+
left over from earlier probes. The finding is documented in the matrix page
|
|
132
|
+
with the suggested spawn-preparation fix.
|
|
133
|
+
|
|
134
|
+
- **The compatibility matrix moves from prose to a measured page.**
|
|
135
|
+
`docs/COMPATIBILITY.md` records the WSL-202 evidence: six real WSL2 distros
|
|
136
|
+
(Ubuntu, Debian 13.5, Fedora 44, openSUSE Tumbleweed, Arch, Alpine 3.22)
|
|
137
|
+
executed the distro-internal probe suite on one machine on 2026-10-26, and
|
|
138
|
+
the page explains the two observed differences instead of hiding them — the
|
|
139
|
+
`session-sandbox` background-survival leg races WSL session teardown on
|
|
140
|
+
three systemd distros (probe artifact, not a plugin claim), and on Alpine
|
|
141
|
+
the same leg fails by declaration, because non-systemd distros get the
|
|
142
|
+
narrower `sessionSandbox: auto` scope exactly as P1.1/WSL-201 promises. The
|
|
143
|
+
page also records the per-distro import traps (Debian OOBE wedge, Alpine
|
|
144
|
+
minirootfs defaulting to root, busybox mktemp, zypper's interactive prompt)
|
|
145
|
+
and the reproduction commands. Not yet measured: real aarch64 hardware, the
|
|
146
|
+
container-image matrix.
|
|
147
|
+
|
|
148
|
+
- **The aarch64 native agent is executed in CI, not only built.** The
|
|
149
|
+
`native packages` job installs qemu's user-mode emulator and binfmt
|
|
150
|
+
registration (`qemu-user` + `qemu-user-binfmt`; noble has no real
|
|
151
|
+
`qemu-user-static` package) and runs the full
|
|
152
|
+
18-check spike suite under qemu-user against both the freshly built aarch64
|
|
153
|
+
musl binary and the committed `agent/native/linux-aarch64/dsh-wsl-agent` —
|
|
154
|
+
the binary a Windows-on-ARM session would load. User-mode emulation
|
|
155
|
+
exercises every check (the static musl binary and the v5 frames are
|
|
156
|
+
host-OS-free), verified locally first: 18/18 under `qemu-aarch64`, with
|
|
157
|
+
`/proc/self/exe` resolving to the target so the HELLO digest binding holds
|
|
158
|
+
unchanged. This closes the "build-tested only" gap on WSL-202; a run on
|
|
159
|
+
real aarch64 hardware remains open.
|
|
160
|
+
|
|
19
161
|
## [0.13.2] - 2026-10-26
|
|
20
162
|
|
|
21
163
|
This release tracks DeepSeek Harness `0.2.1-rc.3`: the peer declarations become
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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 |
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -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 `
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
name containing a newline
|
|
253
|
-
the
|
|
254
|
-
|
|
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
|
|
package/docs/QUICKSTART.md
CHANGED
|
@@ -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
|
+

|
|
41
|
+
|
|
42
|
+
The terminal panel runs in the same distro — `uname -a` answers with the
|
|
43
|
+
distro's kernel, not Windows':
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
And the Sidebar's preview renders WSL files in place:
|
|
48
|
+
|
|
49
|
+

|
|
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
|
+

|
|
64
|
+
|
|
47
65
|
## Recipes
|
|
48
66
|
|
|
49
67
|
Commands run in the model's session, inside the distro, from the workspace root.
|
package/lib/agent-confined.d.ts
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
import { WslAgent } from "./agent.js";
|
|
2
|
+
/**
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
export declare function spawnChecklistScript(root: string): string;
|
|
24
|
+
/**
|
|
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.
|
|
30
|
+
*
|
|
31
|
+
* @param options - the transport, distro, root, and an injectable run.
|
|
32
|
+
* @returns resolves when the checklist passes.
|
|
33
|
+
*/
|
|
34
|
+
export declare function runSpawnChecklist({ wslPath, distro, root, runFn }: {
|
|
35
|
+
wslPath: string;
|
|
36
|
+
distro: string;
|
|
37
|
+
root: string;
|
|
38
|
+
runFn?: (argv: string[]) => Promise<unknown>;
|
|
39
|
+
}): Promise<void>;
|
|
2
40
|
/** One confinement request. */
|
|
3
41
|
export interface ConfinedAgentRequest {
|
|
4
42
|
/** The distro to run in. */
|
package/lib/agent-confined.js
CHANGED
|
@@ -36,6 +36,67 @@ import { toLinuxPath } from "./paths.js";
|
|
|
36
36
|
import { agentScriptDigest, agentScriptPath } from "./agent-shared.js";
|
|
37
37
|
import { WslAgent } from "./agent.js";
|
|
38
38
|
const confinedAgents = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* The resident's spawn checklist, as one `sh -c` body run in the distro.
|
|
41
|
+
*
|
|
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.
|
|
59
|
+
*/
|
|
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]);
|
|
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
|
+
}
|
|
99
|
+
}
|
|
39
100
|
/**
|
|
40
101
|
* The confined agent for one distro and one resolved policy, created on first
|
|
41
102
|
* use and shared for the process lifetime. A success is remembered; a failed
|
|
@@ -48,6 +109,7 @@ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindows
|
|
|
48
109
|
const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}\0${realTmp ? "real" : "ephemeral"}`;
|
|
49
110
|
let agent = confinedAgents.get(key);
|
|
50
111
|
if (agent === undefined) {
|
|
112
|
+
const linuxRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
51
113
|
agent = new WslAgent({
|
|
52
114
|
distro,
|
|
53
115
|
wslPath,
|
|
@@ -58,7 +120,15 @@ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindows
|
|
|
58
120
|
// `wsl.exe --exec` and the `sh <script>` pair (`lib/agent.js`'s
|
|
59
121
|
// `spawnDefault`). Both confinement sites share that assembly in
|
|
60
122
|
// `lib/bwrap.js`, whose module doc records what happened when they did not.
|
|
61
|
-
argvPrefix: bwrapArgvPrefix({ mode: policy.mode, workspaceRoot:
|
|
123
|
+
argvPrefix: bwrapArgvPrefix({ mode: policy.mode, workspaceRoot: linuxRoot }, { maskWindowsDrive, realTmp }),
|
|
124
|
+
// workspace-write is the only mode that binds the workspace root, and
|
|
125
|
+
// bwrap refuses a missing bind source (WSL-305): on a freshly imported
|
|
126
|
+
// distro the first write's workspace may not exist yet, and the resident
|
|
127
|
+
// used to exit during its handshake with a bare code 1. Create the root
|
|
128
|
+
// before the first spawn; every later start re-runs it harmlessly.
|
|
129
|
+
prepare: policy.mode === "workspace-write"
|
|
130
|
+
? () => runSpawnChecklist({ wslPath, distro, root: linuxRoot }).then(() => undefined)
|
|
131
|
+
: undefined,
|
|
62
132
|
});
|
|
63
133
|
confinedAgents.set(key, agent);
|
|
64
134
|
}
|
package/lib/agent.js
CHANGED
|
@@ -352,6 +352,14 @@ export class WslAgent {
|
|
|
352
352
|
// reflected in the state/pending requests; an unhandled stream error
|
|
353
353
|
// would crash the host process instead.
|
|
354
354
|
child.stdin?.on("error", () => { });
|
|
355
|
+
// The resident's own stderr is the only diagnostic a handshake failure
|
|
356
|
+
// carries (bwrap's refusal names the missing bind source, a libc
|
|
357
|
+
// mismatch names itself); surface a bounded tail on the lastError
|
|
358
|
+
// instead of leaving the caller a bare exit code.
|
|
359
|
+
let hsErr = "";
|
|
360
|
+
child.stderr?.on("data", (chunk) => {
|
|
361
|
+
hsErr += String(chunk);
|
|
362
|
+
});
|
|
355
363
|
child.on("exit", (code, signalName) => {
|
|
356
364
|
if (!current()) {
|
|
357
365
|
// A stale child's exit: release its pipes, but never touch the
|
|
@@ -360,7 +368,7 @@ export class WslAgent {
|
|
|
360
368
|
return;
|
|
361
369
|
}
|
|
362
370
|
if (this.state === "starting") {
|
|
363
|
-
this.lastError = `agent exited during handshake (code=${code} signal=${signalName ?? "none"})`;
|
|
371
|
+
this.lastError = `agent exited during handshake (code=${code} signal=${signalName ?? "none"})${hsErr ? ` stderr=${hsErr.trim().slice(-400)}` : ""}`;
|
|
364
372
|
settleStart(new Error(this.lastError));
|
|
365
373
|
}
|
|
366
374
|
this.handleProcessExit(child, code, signalName);
|
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
|
|
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
|
|
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
|
|
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)
|
|
90
|
-
*
|
|
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
|
|
101
|
+
export declare function dirListingArgv(linuxDir: string): string[];
|
|
96
102
|
/**
|
|
97
|
-
* Parse one `
|
|
103
|
+
* Parse one `find -print0` listing into the picker's rows.
|
|
98
104
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
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)
|
|
133
|
-
*
|
|
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
|
|
139
|
-
return ["
|
|
144
|
+
export function dirListingArgv(linuxDir) {
|
|
145
|
+
return ["find", "-L", linuxDir, "-maxdepth", "1", "-mindepth", "1", "-type", "d", "-print0"];
|
|
140
146
|
}
|
|
141
147
|
/**
|
|
142
|
-
* Parse one `
|
|
148
|
+
* Parse one `find -print0` listing into the picker's rows.
|
|
143
149
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
for (const
|
|
160
|
-
if (
|
|
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 =
|
|
163
|
-
|
|
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,
|
|
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:
|
|
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", ...
|
|
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
|
|
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
package/scripts/doctor.mjs
CHANGED
|
@@ -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;
|
|
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 ?? {});
|