dsh-plugin-wsl-env 0.2.0 → 0.4.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,8 +12,302 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.4.0] - 2026-10-03
16
+
17
+ One theme: the enterprise review's remediation cycle completes. The
18
+ security-decision layer gains its own peer-free module and unit tests, the
19
+ coverage gate measures every unit file again, the fallback invariant is probed
20
+ for real, and the file tools pick up the audit's fixes — a killed write no
21
+ longer strands state, large reads stream instead of staging, `/tmp` writes
22
+ land where readers read, cross-distro workdirs are refused, the resident's
23
+ Windows environment is pinned, and `maskWindowsDrive` offers the interop
24
+ mitigation.
25
+
26
+ ### Added
27
+
28
+ - **The filesystem provider's policy decisions are a peer-free module with
29
+ their own unit tests.** The security-decision layer — which paths are
30
+ refused for naming another distro, which mode denies a mutation outright,
31
+ which write guards refuse which intents, how a substrate failure maps onto
32
+ the surfaced refusal, and whether a mutation routes to the confined
33
+ resident — used to live inline in `lib/index.js`, a file no unit test can
34
+ import. It is now `lib/fs-decisions.js` (pure in, refusal descriptor out,
35
+ byte-identical messages), with `test/fs-decisions.test.mjs` covering every
36
+ branch, and `lib/index.js` delegating at one `FsError` choke point.
37
+
38
+ ### Fixed
39
+
40
+ - **The fallback invariant is now probed, not just promised.** PARITY.md's
41
+ hard rule — "`agent: false` must behave like the pre-agent release" — cited
42
+ a probe leg that did not exist, and spelled the key as `wsl.agent: 'off'`
43
+ when it is a boolean. `test/probe/agent.sh` now runs the same operations
44
+ through the resident AND through the one-shot `wsl.exe` path and compares
45
+ outcomes: exit code and stdout bytes (including `$`-metacharacters), stderr
46
+ and working directory, and NUL/CRLF binary safety. All three legs pass
47
+ against a real distro; the doc now names the key correctly and describes
48
+ what the probe actually compares.
49
+
50
+ - **The coverage gate measures every unit file again.** `test:coverage` listed
51
+ six of the sixteen test files — the thresholds (85/85/70) were met by the
52
+ narrowed set while the full suite sat at 82.45% branches, so branch
53
+ regressions anywhere outside the six were invisible to the gate that claimed
54
+ to guard them. The gate now runs the whole unit suite (95.83% lines / 85.95%
55
+ branches / 91.01% functions), which got there the honest way: new tests for
56
+ the seams the audit found dark — the deny-dialect classification,
57
+ aborted-signal mapping, the transport env pin, handshake mismatch, protocol
58
+ garbage, cwd rejections, pre-aborted signals, idle retirement, mid-flight
59
+ over-cap buffers, failed rebuilds, watcher teardown and error paths, the
60
+ cross-distro workdir predicate, staging sweeps, setuid publication, and the
61
+ real-`/tmp` consistency the file tools now have.
62
+
63
+ - **Reading a large file no longer copies its remainder to a temp file for
64
+ every window.** `fs_read` staged `tail -c +offset` to disk before `head`
65
+ took the requested bytes — so a full read of a 1 GB file performed ~1000
66
+ window round trips AND ~500 GB of aggregate temp-file writes inside the
67
+ distro, and a window that took over 30 seconds died as an opaque
68
+ "distro I/O failure". The remainder now streams through a `tail | head`
69
+ pipe (the request's own bound is the only staging), read failures still
70
+ classify through the same stderr dialect, and windowed reads are verified
71
+ byte-exact including the final partial window.
72
+ - **A request the transport cannot deliver now fails its caller instead of
73
+ stranding it.** `writeLines` used to drop a frame silently when the
74
+ `wsl.exe` process was gone (a thin race between exit handling and the next
75
+ request), leaving a pending entry nothing would ever answer — the caller
76
+ waited forever when it had no timeout, and the watchdog had no process
77
+ left to kill. An undeliverable frame now rejects with
78
+ `AgentUnavailableError` ("the agent exited before the request could be
79
+ sent; it never ran"), the rebuild relay fails undeliverable re-sends the
80
+ same way, and `close()` no longer writes `SHUTDOWN` into an ended stdin
81
+ (whose async write-after-end error could crash the process during its own
82
+ shutdown).
83
+
84
+ - **A confined write to `/tmp` now lands in the distro's real `/tmp`, where
85
+ every reader reads.** The fence grants `/tmp` as a writable root, but the
86
+ confined resident's profile mounted an ephemeral tmpfs over it — so the
87
+ write reported success into a private tmpfs, the read tools (unconfined)
88
+ could not see it, the post-write stat produced a `missing:` version that
89
+ made the next edit fail with `FS_STALE_VERSION`, and the file evaporated
90
+ when the resident retired. The file-writes resident's profile now binds the
91
+ real `/tmp` read-write (`realTmp`, on by default for the confined factory):
92
+ the write lands where the fence said it would, reads and edits agree, and
93
+ user terminals see the file. Commands keep their ephemeral per-run tmpfs —
94
+ the fresh-temp-area property (and its anti-poisoning guarantee) is
95
+ unchanged, so a command still cannot see files the write tools placed in
96
+ `/tmp` — the documented command-vs-tools split, now safe in both
97
+ directions. Verified live: confined write → plain read returns the content;
98
+ a confined `cat` still reports it absent, as documented.
99
+
100
+ - **A killed write no longer leaves its staging directory in your files.** A
101
+ `KILL`/`SIGTERM` mid-write could not be trapped, so the private 0700
102
+ staging dir (`.<name>.<pid>.<rand>.tmpdir`) sat in the target's own
103
+ directory forever — and listings showed the debris to the model. The EXIT
104
+ trap now removes the in-flight staging along with the agent's temp dir, and
105
+ each write sweeps the target directory's leftovers whose creating agent's
106
+ PID (it rides the name) is no longer running — a live agent's staging is
107
+ left alone, PID reuse included.
108
+ - **A write to a typo'd path refuses instead of materializing the chain.**
109
+ The staging `mkdir -p` silently created every missing parent directory and
110
+ reported success, where the peer's Node-fs publication fails `ENOENT` —
111
+ masking model errors as wins. Parents are no longer created: the refusal is
112
+ `FS_NOT_FOUND`, an existing directory stays fine, and a concurrent creator
113
+ of the directory still wins.
114
+ - **Setuid, setgid and sticky bits survive an overwrite or edit.** The mode
115
+ was masked to `0o777` at the stat parse and again at the write, so a
116
+ `4755` binary became `755` and a `1777` directory file lost its sticky bit
117
+ — while LIMITATIONS claimed the bits were kept. The full mode now rides
118
+ stat and write end-to-end (`0o7777`), matching the share substrate's
119
+ `chmod --reference` behaviour.
120
+
121
+ - **A shell command whose workdir names another WSL distro is refused, where
122
+ it used to run in the pinned distro against whatever tree shares the path.**
123
+ `toLinuxPath` strips a `\\wsl.localhost\<name>\…` UNC's distro name, and the
124
+ shell executor never compared it — so a `debian` workspace's commands ran
125
+ inside the pinned `ubuntu`, silently, while the fs side refused the same
126
+ workspace with `FS_OUTSIDE_DISTRO` ("open a session in that distro" — which
127
+ the user had done). The shell now refuses with the fs side's wording, and
128
+ the shell-env facts stay silent for a foreign-distro session instead of
129
+ presenting another distro's home and shell as this session's.
130
+
131
+ - **The resident agent no longer inherits the Windows environment's WSLENV
132
+ forwarding.** Spawned bare, `wsl.exe` carried this process's whole
133
+ environment with it, and whatever the user's `WSLENV` names — dev machines
134
+ routinely route secrets (`GITHUB_TOKEN/u` is the documented example) — was
135
+ forwarded into the distro, where every model command inherited it. The
136
+ transport's Windows env is now pinned: the essentials `wsl.exe` itself
137
+ needs, the managed `DSH_*` facts, `WSL_UTF8=1`, and a `WSLENV` that names
138
+ only the managed entries with their original flags. Verified live: a secret
139
+ injected on the Windows side with a forwarding flag appears zero times in
140
+ the distro environment. `forwardEnv` remains one-shot-only today — the
141
+ resident path never forwarded it, and the pin keeps that posture instead of
142
+ widening it silently.
143
+
144
+ ### Added
145
+
146
+ - **`maskWindowsDrive` on `wsl-shell` and `wsl-fs`** — the mitigation knob for
147
+ the interop hole the sandbox has always documented. The confined profile
148
+ mounts an empty tmpfs over `/mnt`, so the Windows drive's files cannot be
149
+ read or exfiltrated by a "sandboxed" command and its executables cannot be
150
+ launched through interop (the path by which such a command reaches your full
151
+ Windows token). Honest limits, stated wherever the key is: binfmt interop
152
+ dispatches on file content, so a command that can write the workspace can
153
+ still write an executable there and run it — enforcement stays `partial`,
154
+ the result's sandbox fact now carries `windowsDrive: "masked" | "visible"`
155
+ so the model can see which posture it runs under, and the only complete
156
+ closure is distro-level (`[interop] enabled=false` in `wsl.conf`). Default
157
+ `false`: the drive stays visible unless you ask for the mask.
158
+
159
+ ## [0.3.0] - 2026-10-03
160
+
161
+ One theme: the file tools move into the distro. The `substrate: "agent"` I/O
162
+ substrate and its kernel-enforced confined writes land together with the
163
+ enterprise review that hardened them — the watchdog that killed the shared
164
+ agent for a queue it never chose to join, the output captures nothing bounded,
165
+ a root listing that sheared the first character off every child's name, and an
166
+ overwrite guard that stopped at the host.
167
+
168
+ ### Fixed
169
+
170
+ - **One file carries one version string, whichever op answered.** `stat` and
171
+ `list` built their versions from different producers: the stat op used the
172
+ distro's `stat` directives (uutils `stat` rounds timestamps to microseconds)
173
+ while the list walk used `find -printf` (nanosecond-exact), so the same file
174
+ yielded two different version strings and a list-derived guard failed
175
+ against its own stat. `fs_stat` now reads through the same `find -printf`
176
+ the list walk uses — one producer, one string — and `find -L`'s habit of
177
+ reporting a dangling link as the link itself is refused the way the `stat`
178
+ it replaced was, so a dangling link still stats to null like the peer's.
179
+
180
+ - **An overwrite or edit now re-verifies its version distro-side, one syscall
181
+ before the rename.** The module header claimed the mutation guard "survives
182
+ to the write", but the no-replace link guarded only `createIfAbsent`: an
183
+ overwrite or edit checked the version host-side and then published with an
184
+ unconditional `mv -f`, so a concurrent writer (an editor save, `git stash`,
185
+ another session) that landed between the model's stat and the publish was
186
+ silently clobbered with no `FS_STALE_VERSION`. The `write` op now carries
187
+ the version the write was based on (a `replaceIfVersion` guard's version,
188
+ or the stat an edit was read against), and the agent re-verifies it with a
189
+ fresh stat from the SAME producer `fs_stat` uses — refusing with
190
+ `FS_STALE_VERSION` ("file changed since it was read", or "file no longer
191
+ exists") while the concurrent writer's content stays untouched and no
192
+ staging debris is left behind. The window narrows from a host round trip to
193
+ kernel-adjacent — the honest wording, here and in the module headers,
194
+ LIMITATIONS.md and ARCHITECTURE.md, is that only the create case is closed
195
+ outright.
196
+
197
+ - **The agent command path now enforces the output budget it always
198
+ advertised.** `maxOutputBytes` rode the config schema but nothing else: the
199
+ agent captured a command's whole stdout and stderr and base64ed both into
200
+ one RES line — multi-gigabyte output meant multi-gigabyte dash command
201
+ substitutions in the distro and a multi-gigabyte host line, with the result
202
+ claiming `truncated: false` all the way. The EXEC frame now carries the
203
+ caller's per-stream budget (`wsl-shell`'s `maxOutputBytes`, wired from the
204
+ agent execution path); the agent cuts each stream at it — only the first
205
+ bytes are encoded, so distro AND host memory are bounded — and the RES line
206
+ reports which streams were cut, which the handle surfaces as honest
207
+ per-stream `truncated` flags (a guarded-create synthesis and frames without
208
+ a budget still read `false`). The host also defends itself against a broken
209
+ or hostile agent: the unbounded readline accumulator is replaced with a
210
+ bounded line splitter that kills the transport when one stdout line exceeds
211
+ the protocol cap instead of buffering it whole.
212
+
213
+ - **A short-budget request queued behind a long one no longer kills the shared
214
+ agent — and an in-flight command is never silently re-run.** Two defects
215
+ shared one root: the host armed each request's watchdog at SUBMISSION and
216
+ let it kill the one `wsl.exe` carrying every pending request, and the
217
+ rebuild relay then replayed all carried frames verbatim. In the default
218
+ configuration the 10 s `DSH_WSL_PORTS` poller (8 s budget, shared agent) did
219
+ exactly this against any command longer than ~21 s: the poll's watchdog
220
+ killed the transport, the command died mid-flight, and the rebuild
221
+ **re-executed it from scratch** — both callers seeing success (measured: a
222
+ killed `sleep 40` ran twice). Three changes close it: the agent answers
223
+ `ACK|<id>` when it dequeues a request and the host arms the watchdog there,
224
+ so queue time never spends a budget (wire protocol v3 — sync the runtime
225
+ copy and restart the app, an old agent fails the handshake loudly instead of
226
+ misbehaving quietly); the rebuild relays only provably read-only FS frames
227
+ (`stat`/`lstat`/`list`/`realpath`/`read`), failing an in-flight EXEC or
228
+ `write` with "its state is unknown — inspect the distro before retrying"
229
+ instead of re-running it; and an abort now addresses the request's CURRENT
230
+ id, so a relayed request's KILL (and map cleanup) can no longer miss. The
231
+ watchdog-vs-queue experiment now ends with the long command executed exactly
232
+ once and the transport alive.
233
+ - **A root listing kept every child's name whole.** `listChildren` sliced each
234
+ `find` record at `parent.length + 1`, which against the bare `/` parent
235
+ sheared the first character off every name (`/etc` → `tc`), and a write to a
236
+ listed child then created `/tc`. The exact prefix is stripped instead, and
237
+ the child join owes its own slash, so `listDir` on
238
+ `\\wsl.localhost\<distro>\` names `/etc`, `/home`, `/usr` correctly.
239
+
240
+ ### Added
241
+
242
+ - **`substrate: "agent"` — the filesystem's in-distro I/O substrate.** `wsl-fs`
243
+ gains a `substrate` key, default `agent`: the file tools are served through
244
+ the resident in-distro agent instead of the 9p share — identities are
245
+ distro-side `realpath`s, symlinks resolve where they live (no retry
246
+ machinery, `/etc/os-release` just works), mode bits are native, and a
247
+ mutation guard survives to the write's no-replace publication — the
248
+ check-then-write window the share documents closes, exactly as on a Linux
249
+ host. `substrate: "share"` is the opt-out that keeps the previous behaviour
250
+ byte for byte. Both substrates fence writes with the same host-side policy
251
+ check, so this changes fidelity and posture, not enforcement. Known
252
+ differences are in LIMITATIONS.md (notably: new files publish at 0600, the
253
+ peer's own POSIX semantics, where the share took the umask 0644), and an
254
+ agent outage fails with `FS_IO_ERROR` naming the substrate rather than
255
+ degrading to share semantics. Layers: FS operation frames on the agent wire
256
+ protocol (v3, `agent/wsl-agent.sh`), fsio-over-agent mechanics
257
+ (`lib/fsio-agent.js`), provider-shaped orchestration (`lib/fs-substrate.js`),
258
+ and the provider branch (`lib/index.js`). The pure text mechanics are
259
+ replicated from upstream pending the fsio export proposed in
260
+ docs/UPSTREAM-FSIO-EXPORT.md. Probed live: `npm run probe:substrate`.
261
+ - **Stage two: kernel-enforced confined writes.** On the agent substrate, a
262
+ mutation under a confined policy rides its own long-lived resident — one
263
+ agent per (distro, mode, workspace), spawned inside the bwrap profile that
264
+ binds exactly what the mode grants (`lib/agent-confined.js`, `argvPrefix` on
265
+ the agent wire). The kernel now refuses what the host-side check would have,
266
+ so a check bug cannot become a write outside the workspace; a mount-table
267
+ refusal surfaces as `FS_SANDBOX_DENIED`, the command path's dialect, and a
268
+ distro without usable bwrap refuses confined writes with the bootstrap
269
+ command instead of downgrading. Escalated (`danger-full-access`) writes ride
270
+ the plain resident, and a policy change simply addresses a different key —
271
+ the previous resident retires through its idle timer. Probed live: the
272
+ confined write inside the workspace lands, and the one outside it is refused
273
+ by the kernel (`probe:substrate`).
274
+
15
275
  ### Fixed
16
276
 
277
+ - **Every mutation on the agent substrate failed: the confined resident's command
278
+ had no program** — `confinedAgent()` built the resident's `argvPrefix` from the
279
+ raw profile arguments, so the transport handed `wsl.exe` a command beginning at
280
+ `--ro-bind` instead of `bwrap --ro-bind`. The resident exited 1 during its
281
+ handshake, and every write, edit and guarded create on `substrate: "agent"`
282
+ failed with `FS_IO_ERROR: the distro file substrate is unavailable: agent exited
283
+ during handshake (code=1 signal=none)` while reads kept working. Both confinement
284
+ sites now assemble the command through the import-free `lib/bwrap.js`, which is
285
+ what `test/agent.test.mjs` asserts; the same bug stayed invisible because
286
+ `test/probe/substrate.sh` inlined a *third*, correct copy of those arguments —
287
+ it now imports the production builder, so the probe fails with the provider.
288
+ - **`WslFileSystem` could not build its agent substrate at all: a constructor
289
+ field shadowed the method of the same name** — the constructor's
290
+ `this.agentSubstrate = undefined` (the cache) overwrote the prototype's
291
+ `async agentSubstrate()` (the accessor), so the first file operation that needed
292
+ the agent threw `this.agentSubstrate is not a function`, which is how a GUI
293
+ session failed its turn in the Desktop app. The cache is now
294
+ `this.agentSubstrateInstance`, named after the sibling `this.agentInstance`, and
295
+ `lib/index.js`'s instance fields no longer collide with any class member.
296
+ - **A schemastery member the pinned peer does not publish took the `wsl` preset
297
+ down in the Desktop app** — `WslFileSystem.Config` declared
298
+ `substrate: z.enum(["share", "agent"])`, and `@deepseek-ai/schemastery` 3.18.4
299
+ (what every `@deepseek-ai/dsh-*` peer pins as `~3.18.4`) publishes no `enum`. A
300
+ Config schema is a class field, so the call ran while `lib/index.js` was still
301
+ being imported: the loader logs the `TypeError` and leaves that row with no
302
+ fiber, which the preset audit renders as `never started` — naming neither the
303
+ member nor the file, and only for `wsl-shell` and `wsl-fs`, the two rows that
304
+ load `lib/index.js`. The preset therefore failed to activate, so the app
305
+ reported `2 row(s) did not activate` for those two ids. Now
306
+ `z.union(["share", "agent"])`: same `agent` default, and a bad value still
307
+ fails with `expected "share" | "agent" but got "bogus"`. Measured against the
308
+ app's own runtime, both entries import; `test/syntax.mjs` now checks every
309
+ `z.<member>` in `lib/` against the pinned peer's surface, which fails on
310
+ `z.enum` and passes on `z.union`.
17
311
  - **`truncated` missing from the agent path's output streams** — the bash tool's
18
312
  canonical result copies the field unconditionally (`canonicalBashResult`), so an
19
313
  `undefined` failed the wire's lossless-JSON snapshot and EVERY shell tool call
package/README.md CHANGED
@@ -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
 
@@ -118,6 +119,7 @@ npm run probe:sandbox # measure inside the distro what bubblewrap does an
118
119
  npm run probe # filesystem probe against a real distro (Windows + WSL only)
119
120
  npm run probe:sandbox-shell # boot a real harness and drive the confined executor
120
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)
121
123
  npm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
122
124
  npm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
123
125
  npm run probe:mode # which POSIX-mode facts survive the share (Windows node only)