dsh-plugin-wsl-env 0.1.10 → 0.3.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
@@ -12,6 +12,274 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ### Added
16
+
17
+ - **`maskWindowsDrive` on `wsl-shell` and `wsl-fs`** — the mitigation knob for
18
+ the interop hole the sandbox has always documented. The confined profile
19
+ mounts an empty tmpfs over `/mnt`, so the Windows drive's files cannot be
20
+ read or exfiltrated by a "sandboxed" command and its executables cannot be
21
+ launched through interop (the path by which such a command reaches your full
22
+ Windows token). Honest limits, stated wherever the key is: binfmt interop
23
+ dispatches on file content, so a command that can write the workspace can
24
+ still write an executable there and run it — enforcement stays `partial`,
25
+ the result's sandbox fact now carries `windowsDrive: "masked" | "visible"`
26
+ so the model can see which posture it runs under, and the only complete
27
+ closure is distro-level (`[interop] enabled=false` in `wsl.conf`). Default
28
+ `false`: the drive stays visible unless you ask for the mask.
29
+
30
+ ## [0.3.0] - 2026-10-03
31
+
32
+ One theme: the file tools move into the distro. The `substrate: "agent"` I/O
33
+ substrate and its kernel-enforced confined writes land together with the
34
+ enterprise review that hardened them — the watchdog that killed the shared
35
+ agent for a queue it never chose to join, the output captures nothing bounded,
36
+ a root listing that sheared the first character off every child's name, and an
37
+ overwrite guard that stopped at the host.
38
+
39
+ ### Fixed
40
+
41
+ - **One file carries one version string, whichever op answered.** `stat` and
42
+ `list` built their versions from different producers: the stat op used the
43
+ distro's `stat` directives (uutils `stat` rounds timestamps to microseconds)
44
+ while the list walk used `find -printf` (nanosecond-exact), so the same file
45
+ yielded two different version strings and a list-derived guard failed
46
+ against its own stat. `fs_stat` now reads through the same `find -printf`
47
+ the list walk uses — one producer, one string — and `find -L`'s habit of
48
+ reporting a dangling link as the link itself is refused the way the `stat`
49
+ it replaced was, so a dangling link still stats to null like the peer's.
50
+
51
+ - **An overwrite or edit now re-verifies its version distro-side, one syscall
52
+ before the rename.** The module header claimed the mutation guard "survives
53
+ to the write", but the no-replace link guarded only `createIfAbsent`: an
54
+ overwrite or edit checked the version host-side and then published with an
55
+ unconditional `mv -f`, so a concurrent writer (an editor save, `git stash`,
56
+ another session) that landed between the model's stat and the publish was
57
+ silently clobbered with no `FS_STALE_VERSION`. The `write` op now carries
58
+ the version the write was based on (a `replaceIfVersion` guard's version,
59
+ or the stat an edit was read against), and the agent re-verifies it with a
60
+ fresh stat from the SAME producer `fs_stat` uses — refusing with
61
+ `FS_STALE_VERSION` ("file changed since it was read", or "file no longer
62
+ exists") while the concurrent writer's content stays untouched and no
63
+ staging debris is left behind. The window narrows from a host round trip to
64
+ kernel-adjacent — the honest wording, here and in the module headers,
65
+ LIMITATIONS.md and ARCHITECTURE.md, is that only the create case is closed
66
+ outright.
67
+
68
+ - **The agent command path now enforces the output budget it always
69
+ advertised.** `maxOutputBytes` rode the config schema but nothing else: the
70
+ agent captured a command's whole stdout and stderr and base64ed both into
71
+ one RES line — multi-gigabyte output meant multi-gigabyte dash command
72
+ substitutions in the distro and a multi-gigabyte host line, with the result
73
+ claiming `truncated: false` all the way. The EXEC frame now carries the
74
+ caller's per-stream budget (`wsl-shell`'s `maxOutputBytes`, wired from the
75
+ agent execution path); the agent cuts each stream at it — only the first
76
+ bytes are encoded, so distro AND host memory are bounded — and the RES line
77
+ reports which streams were cut, which the handle surfaces as honest
78
+ per-stream `truncated` flags (a guarded-create synthesis and frames without
79
+ a budget still read `false`). The host also defends itself against a broken
80
+ or hostile agent: the unbounded readline accumulator is replaced with a
81
+ bounded line splitter that kills the transport when one stdout line exceeds
82
+ the protocol cap instead of buffering it whole.
83
+
84
+ - **A short-budget request queued behind a long one no longer kills the shared
85
+ agent — and an in-flight command is never silently re-run.** Two defects
86
+ shared one root: the host armed each request's watchdog at SUBMISSION and
87
+ let it kill the one `wsl.exe` carrying every pending request, and the
88
+ rebuild relay then replayed all carried frames verbatim. In the default
89
+ configuration the 10 s `DSH_WSL_PORTS` poller (8 s budget, shared agent) did
90
+ exactly this against any command longer than ~21 s: the poll's watchdog
91
+ killed the transport, the command died mid-flight, and the rebuild
92
+ **re-executed it from scratch** — both callers seeing success (measured: a
93
+ killed `sleep 40` ran twice). Three changes close it: the agent answers
94
+ `ACK|<id>` when it dequeues a request and the host arms the watchdog there,
95
+ so queue time never spends a budget (wire protocol v3 — sync the runtime
96
+ copy and restart the app, an old agent fails the handshake loudly instead of
97
+ misbehaving quietly); the rebuild relays only provably read-only FS frames
98
+ (`stat`/`lstat`/`list`/`realpath`/`read`), failing an in-flight EXEC or
99
+ `write` with "its state is unknown — inspect the distro before retrying"
100
+ instead of re-running it; and an abort now addresses the request's CURRENT
101
+ id, so a relayed request's KILL (and map cleanup) can no longer miss. The
102
+ watchdog-vs-queue experiment now ends with the long command executed exactly
103
+ once and the transport alive.
104
+ - **A root listing kept every child's name whole.** `listChildren` sliced each
105
+ `find` record at `parent.length + 1`, which against the bare `/` parent
106
+ sheared the first character off every name (`/etc` → `tc`), and a write to a
107
+ listed child then created `/tc`. The exact prefix is stripped instead, and
108
+ the child join owes its own slash, so `listDir` on
109
+ `\\wsl.localhost\<distro>\` names `/etc`, `/home`, `/usr` correctly.
110
+
111
+ ### Added
112
+
113
+ - **`substrate: "agent"` — the filesystem's in-distro I/O substrate.** `wsl-fs`
114
+ gains a `substrate` key, default `agent`: the file tools are served through
115
+ the resident in-distro agent instead of the 9p share — identities are
116
+ distro-side `realpath`s, symlinks resolve where they live (no retry
117
+ machinery, `/etc/os-release` just works), mode bits are native, and a
118
+ mutation guard survives to the write's no-replace publication — the
119
+ check-then-write window the share documents closes, exactly as on a Linux
120
+ host. `substrate: "share"` is the opt-out that keeps the previous behaviour
121
+ byte for byte. Both substrates fence writes with the same host-side policy
122
+ check, so this changes fidelity and posture, not enforcement. Known
123
+ differences are in LIMITATIONS.md (notably: new files publish at 0600, the
124
+ peer's own POSIX semantics, where the share took the umask 0644), and an
125
+ agent outage fails with `FS_IO_ERROR` naming the substrate rather than
126
+ degrading to share semantics. Layers: FS operation frames on the agent wire
127
+ protocol (v3, `agent/wsl-agent.sh`), fsio-over-agent mechanics
128
+ (`lib/fsio-agent.js`), provider-shaped orchestration (`lib/fs-substrate.js`),
129
+ and the provider branch (`lib/index.js`). The pure text mechanics are
130
+ replicated from upstream pending the fsio export proposed in
131
+ docs/UPSTREAM-FSIO-EXPORT.md. Probed live: `npm run probe:substrate`.
132
+ - **Stage two: kernel-enforced confined writes.** On the agent substrate, a
133
+ mutation under a confined policy rides its own long-lived resident — one
134
+ agent per (distro, mode, workspace), spawned inside the bwrap profile that
135
+ binds exactly what the mode grants (`lib/agent-confined.js`, `argvPrefix` on
136
+ the agent wire). The kernel now refuses what the host-side check would have,
137
+ so a check bug cannot become a write outside the workspace; a mount-table
138
+ refusal surfaces as `FS_SANDBOX_DENIED`, the command path's dialect, and a
139
+ distro without usable bwrap refuses confined writes with the bootstrap
140
+ command instead of downgrading. Escalated (`danger-full-access`) writes ride
141
+ the plain resident, and a policy change simply addresses a different key —
142
+ the previous resident retires through its idle timer. Probed live: the
143
+ confined write inside the workspace lands, and the one outside it is refused
144
+ by the kernel (`probe:substrate`).
145
+
146
+ ### Fixed
147
+
148
+ - **Every mutation on the agent substrate failed: the confined resident's command
149
+ had no program** — `confinedAgent()` built the resident's `argvPrefix` from the
150
+ raw profile arguments, so the transport handed `wsl.exe` a command beginning at
151
+ `--ro-bind` instead of `bwrap --ro-bind`. The resident exited 1 during its
152
+ handshake, and every write, edit and guarded create on `substrate: "agent"`
153
+ failed with `FS_IO_ERROR: the distro file substrate is unavailable: agent exited
154
+ during handshake (code=1 signal=none)` while reads kept working. Both confinement
155
+ sites now assemble the command through the import-free `lib/bwrap.js`, which is
156
+ what `test/agent.test.mjs` asserts; the same bug stayed invisible because
157
+ `test/probe/substrate.sh` inlined a *third*, correct copy of those arguments —
158
+ it now imports the production builder, so the probe fails with the provider.
159
+ - **`WslFileSystem` could not build its agent substrate at all: a constructor
160
+ field shadowed the method of the same name** — the constructor's
161
+ `this.agentSubstrate = undefined` (the cache) overwrote the prototype's
162
+ `async agentSubstrate()` (the accessor), so the first file operation that needed
163
+ the agent threw `this.agentSubstrate is not a function`, which is how a GUI
164
+ session failed its turn in the Desktop app. The cache is now
165
+ `this.agentSubstrateInstance`, named after the sibling `this.agentInstance`, and
166
+ `lib/index.js`'s instance fields no longer collide with any class member.
167
+ - **A schemastery member the pinned peer does not publish took the `wsl` preset
168
+ down in the Desktop app** — `WslFileSystem.Config` declared
169
+ `substrate: z.enum(["share", "agent"])`, and `@deepseek-ai/schemastery` 3.18.4
170
+ (what every `@deepseek-ai/dsh-*` peer pins as `~3.18.4`) publishes no `enum`. A
171
+ Config schema is a class field, so the call ran while `lib/index.js` was still
172
+ being imported: the loader logs the `TypeError` and leaves that row with no
173
+ fiber, which the preset audit renders as `never started` — naming neither the
174
+ member nor the file, and only for `wsl-shell` and `wsl-fs`, the two rows that
175
+ load `lib/index.js`. The preset therefore failed to activate, so the app
176
+ reported `2 row(s) did not activate` for those two ids. Now
177
+ `z.union(["share", "agent"])`: same `agent` default, and a bad value still
178
+ fails with `expected "share" | "agent" but got "bogus"`. Measured against the
179
+ app's own runtime, both entries import; `test/syntax.mjs` now checks every
180
+ `z.<member>` in `lib/` against the pinned peer's surface, which fails on
181
+ `z.enum` and passes on `z.union`.
182
+ - **`truncated` missing from the agent path's output streams** — the bash tool's
183
+ canonical result copies the field unconditionally (`canonicalBashResult`), so an
184
+ `undefined` failed the wire's lossless-JSON snapshot and EVERY shell tool call
185
+ returned "tool bash returned invalid output: value is not lossless JSON". The
186
+ agent path now always reports `truncated: false` (it never truncates; there are
187
+ no spill files). Regression assertion in `test/agent-exec.test.mjs`.
188
+ - **The agent execution path failed every shell call whose request carried an
189
+ absolute UNC workdir** — which is every call a GUI session makes, so the
190
+ harness's own agent session lost its bash tool until the fix. The one-shot
191
+ path converts the spec's workdir inside `argv()` (`toLinuxPath`); the agent
192
+ path carried the workdir as a raw frame field, so the raw UNC reached the
193
+ in-distro `chdir`, the synthesized relay text fired `throwIfWorkdirMissing`,
194
+ and the call died with "could not enter the working directory". The conversion
195
+ now happens at the call site (regression test in `test/shell.test.mjs`).
196
+
197
+ ### Added
198
+
199
+ - **Phase 5: per-session terminal routing.** `spawnTerminal` now routes on the
200
+ request's directory coordinates (`lib/terminal-route.js`, `subprocess-wsl.hostSessions`,
201
+ default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
202
+ a host `powershell.exe` in its own directory — no more distro terminal at
203
+ `/mnt/<drive>/…` for host sessions. The shell menu stays the single configured profile,
204
+ and every non-`wsl.exe` consumer is untouched. Still limited (LIMITATIONS.md): the
205
+ shell menu is one entry, and directory-less launches land in the distro.
206
+ - **Phase 7: developer-loop recipes.** Both READMEs gain a Recipes section: Git
207
+ credential sharing through the Windows Credential Manager, `/mnt/c` 9p
208
+ performance guidance, and the `WSLENV` passthrough policy (prefix-admitted
209
+ `DSH_*`, `/p` translation, `PATH` deliberately excluded).
210
+ - **Phase 6: listening-port visibility.** `DSH_WSL_PORTS` joins the managed
211
+ `DSH_*` facts: the ports with a listener inside the distro, comma-separated,
212
+ refreshed every `portsRefreshMs` (default 10s) through the resident agent by
213
+ reading `/proc/net/tcp{,6}` directly — no `ss`, nothing to install. The
214
+ registry's resolve is synchronous by contract, so the snapshot is served
215
+ stale-but-recent; a dev server started moments ago appears on the next
216
+ refresh. Scoped as before: only sessions whose workspace is inside a distro
217
+ see it. Probed live: a real HTTP server appears in the next snapshot.
218
+ - **Phase 4: bootstrap.** `npm run bootstrap -- [distro] [--install]` checks the
219
+ distro for bubblewrap (required) plus ripgrep and inotifywait (the optional
220
+ backends later phases prefer), prints the exact copy-paste install command for
221
+ the distro's package family (apt/dnf/pacman/zypper), and with `--install` runs
222
+ it as the distro's root through `wsl.exe -u root` — no sudo prompt inside the
223
+ distro, nothing touches the Windows side. The `SANDBOX_UNAVAILABLE` message now
224
+ names the same command instead of a bare `sudo apt install`. Probed live:
225
+ detect mode on a distro that already has bubblewrap.
226
+ - **Phase 3: shell execution through the agent.** `WslShellExecutor` now runs commands
227
+ over the resident agent when it is up (`wsl-shell.agent`, default on): one long-lived
228
+ in-distro process instead of a `wsl.exe` per command, with the timeout enforced
229
+ INSIDE the distro (TERM, then KILL after the grace) instead of against the Windows
230
+ process tree. The confinement argv, the execution-handle shape, the result
231
+ decoration (runner failures, denials, `enforcement: partial`) and the workdir
232
+ failure are all identical to the one-shot path — an out-of-service agent falls back
233
+ to it per call, permanently after the agent's rebuild budget. Probed end-to-end:
234
+ `npm run probe:exec` (confined completion, verbatim shell parsing, in-distro
235
+ timeout, relay-shaped cwd failure, kill). Documented deviation: the agent path has
236
+ no live output streaming — reads after settlement carry the whole stream, and no
237
+ spill files (the protocol line already holds it in memory).
238
+ - **Phase 2 (second half, first slice): in-distro publication through the agent.**
239
+ `WslFileSystem` now carries an optional resident agent (`wsl-fs.agent`, default on):
240
+ when available, publication — the replaced file's mode copied onto the staged temp,
241
+ then the atomic rename — is ONE `exec` inside the distro (`lib/fs-publish.js`)
242
+ instead of a `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
243
+ legacy two-step path for that call; the flag changes round trips, never what is
244
+ published. Probed: the publication script's mode preservation and creation
245
+ behaviour run in `test/probe/agent.sh` (now 10 checks).
246
+ - **Phase 2 (first half): real `watch()`.** `WslFileSystem.watch` now observes from
247
+ inside the distro — a long-lived `wsl.exe` `find -newer` poll loop (`lib/watcher.js`)
248
+ firing the seam's coarse invalidation callback — instead of refusing with
249
+ `FS_IO_ERROR`. Activates on a READY barrier from the loop (a write racing the first
250
+ tick is never invisible), sees creations and deletions (both bump the parent's
251
+ mtime), and cleans up on close. Documented blind spot: files created with an
252
+ mtime older than the stamp (`cp -p`, `tar -x`). Probed end-to-end:
253
+ `npm run probe:watch`. The native-ext4 migration of the remaining fs operations
254
+ stays with Phase 3 of the plan.
255
+ - **Phase 1 of the Remote-WSL parity plan (`docs/PARITY.md`): the resident in-distro
256
+ agent.** `agent/wsl-agent.sh` is a dependency-free POSIX shell peer spawned once per
257
+ distro; `lib/agent.js` owns the lifecycle (handshake, lazy start, idle shutdown, one
258
+ rebuild, permanent fallback to the shipped one-shot `wsl.exe` path) and
259
+ `lib/agent-protocol.js` the line protocol (base64 payloads, NUL-safe argv). Not wired
260
+ into the seams yet — the executor and filesystem migrations are the next phases.
261
+ Probed end-to-end against a real distro: `test/probe/agent.sh` (8 checks, including
262
+ binary-safe payloads, an in-distro timeout kill, and a 2 MB single-line response).
263
+ Unit tests cover the codecs and the lifecycle state machine with a fake transport.
264
+
265
+ ### Added
266
+
267
+ - `docs/PARITY.md`: the plan to reach VS Code Remote-WSL-grade experience — a resident
268
+ in-distro agent as the core, with the `fs` and `shell` seams migrated onto it and the
269
+ current 9p/one-shot paths kept as the documented fallback — plus the Phase-0 contract
270
+ audit that shaped it (watch is a coarse invalidation callback; search spawns the
271
+ packaged ripgrep through `ctx.subprocess`, so the interception point is the
272
+ `subprocess-wsl` subclass).
273
+
274
+ ### Changed
275
+
276
+ - The package description is rewritten in Chinese to state what the plugin does first —
277
+ it makes a WSL distro the session's execution environment — then covers the execution,
278
+ UI, and sandbox aspects in one clause each.
279
+ - Chinese text now says “WSL 子系统” instead of “WSL 发行版”, the phrasing Chinese readers
280
+ more commonly use for a WSL distro (`docs/README.zh.md` and the two archived Chinese
281
+ documents; 99 occurrences).
282
+
15
283
  ## [0.1.10] - 2026-10-02
16
284
 
17
285
  Metadata and one documentation addition. No behaviour change.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Step 3 should print a layer named `# == dsh-plugin-wsl-env`, and `- id: terminal
28
28
 
29
29
  Then open a folder under `\\wsl.localhost\<distro>\...` in the GUI. The picker lists every installed distro at its root level, and **New terminal** opens a shell in the distro.
30
30
 
31
- You also need **bubblewrap inside the distro**: `sudo apt install bubblewrap`. Without it every command fails closed, see [Sandbox](#sandbox).
31
+ You also need **bubblewrap inside the distro**: run `npm run bootstrap -- <distro> --install` (drop `--install` for a read-only check), or paste `wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap`. Without it every command fails closed, see [Sandbox](#sandbox).
32
32
 
33
33
  Uninstall: `dsh plugin --profile wsl remove dsh-plugin-wsl-env`. Upgrade: run the same `add` command again.
34
34
 
@@ -56,6 +56,7 @@ Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys w
56
56
  | `wsl-fs` | `distro` | `''` | as above |
57
57
  | | `restrictToDistro` | `true` | refuse a path in **another distro**'s share; `/mnt/c` is inside this distro and is not affected. Refused with `FS_OUTSIDE_DISTRO`, which is not a sandbox denial and cannot be lifted by wider permissions |
58
58
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
59
+ | | `substrate` | `agent` | which I/O substrate serves the file tools: `agent` — the resident in-distro agent, where reads, writes and identities run on ext4 (native symlinks and mode bits; the write guard survives to publication, kernel-enforced under a confined policy); `share` — the opt-out: the Windows-side host stack over the 9p share. Both fence writes with the same policy; see [LIMITATIONS.md](docs/LIMITATIONS.md) for what changes |
59
60
  | `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory |
60
61
  | `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
61
62
 
@@ -77,11 +78,28 @@ Commands are confined by `bubblewrap` inside the distro, and file writes are che
77
78
 
78
79
  See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [docs/LIMITATIONS.md](docs/LIMITATIONS.md) for everything the plugin does not do.
79
80
 
81
+ ## Recipes
82
+
83
+ - **Git credential sharing**: let git inside the distro use the Windows-side Git
84
+ Credential Manager — `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
85
+ (adjust the path to your Windows Git install; WSL2's localhost forwarding is
86
+ platform behaviour, so ports a dev server listens on inside the distro are
87
+ reachable from Windows directly).
88
+ - **Paths and performance**: the model sees and operates on Linux paths
89
+ (`/home/...`) on the distro's own ext4. `/mnt/c` reaches the Windows disk over
90
+ 9p — noticeably slow for many small files; keep heavy-IO projects on the
91
+ distro filesystem. `npm run bootstrap -- <distro>` also reports whether
92
+ ripgrep and inotifywait (the search and watch backends) are in place.
93
+ - **WSLENV passthrough**: WSL imports only the variables listed in `WSLENV`.
94
+ This plugin admits the managed `DSH_*` namespace by prefix, translating the
95
+ two Windows-path ones (`DSH_HOME`, `DSH_PROFILE_DIR`) with `/p`. `PATH` is
96
+ deliberately never forwarded — it would shadow the distro's own PATH.
97
+
80
98
  ## Troubleshooting
81
99
 
82
100
  | Symptom | Cause | Fix |
83
101
  |---|---|---|
84
- | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is not installed in the distro | run `sudo apt install bubblewrap`, or set `sandbox: false` on both providers |
102
+ | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is not installed in the distro | run `npm run bootstrap -- <distro> --install`, or set `sandbox: false` on both providers |
85
103
  | a command or write is refused outside the session folder | expected behaviour of `workspace-write` | accept the wider-permission offer, or open a session on the folder you need |
86
104
  | writes are refused even inside the workspace | the session is in `read-only` mode | switch the Permissions selector |
87
105
  | `dsh plugin add` warns that no layer was activated | the dependency was already installed, so `add` had nothing to record | run `dsh plugin --profile wsl remove dsh-plugin-wsl-env`, then add it again |
@@ -101,6 +119,7 @@ npm run probe:sandbox # measure inside the distro what bubblewrap does an
101
119
  npm run probe # filesystem probe against a real distro (Windows + WSL only)
102
120
  npm run probe:sandbox-shell # boot a real harness and drive the confined executor
103
121
  npm run probe:terminal # open a PTY through the terminal provider
122
+ npm run probe:substrate # drive the agent filesystem substrate over a real wsl.exe transport (from inside the distro)
104
123
  npm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
105
124
  npm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
106
125
  npm run probe:mode # which POSIX-mode facts survive the share (Windows node only)