dsh-plugin-wsl-env 0.9.0 → 0.10.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +417 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +84 -68
  4. package/SUPPORT.md +3 -3
  5. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  6. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  7. package/agent/wsl-agent.sh +83 -16
  8. package/cordis.patch.yml +41 -35
  9. package/docs/ARCHITECTURE.md +9 -1
  10. package/docs/CONFIGURATION.md +7 -4
  11. package/docs/LIMITATIONS.md +53 -18
  12. package/docs/PARITY.md +3 -1
  13. package/docs/PEER-PARITY.md +18 -11
  14. package/docs/README.zh.md +97 -74
  15. package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
  16. package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
  17. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  18. package/lib/agent-protocol.d.ts +10 -4
  19. package/lib/agent-protocol.js +10 -3
  20. package/lib/agent.d.ts +84 -0
  21. package/lib/agent.js +167 -15
  22. package/lib/bwrap.d.ts +8 -0
  23. package/lib/bwrap.js +4 -1
  24. package/lib/fs-routing.d.ts +40 -4
  25. package/lib/fs-routing.js +80 -12
  26. package/lib/fsio-agent.js +1 -1
  27. package/lib/index.d.ts +134 -0
  28. package/lib/index.js +191 -3
  29. package/lib/native-agent.d.ts +124 -0
  30. package/lib/native-agent.js +198 -0
  31. package/lib/ports.d.ts +13 -2
  32. package/lib/ports.js +18 -5
  33. package/lib/preflight.js +24 -1
  34. package/lib/session-runtime.d.ts +161 -0
  35. package/lib/session-runtime.js +220 -0
  36. package/lib/session-scope.d.ts +124 -0
  37. package/lib/session-scope.js +191 -0
  38. package/lib/shell-env.js +10 -3
  39. package/lib/watcher.d.ts +25 -12
  40. package/lib/watcher.js +120 -32
  41. package/lib/workspace-files-route.d.ts +8 -4
  42. package/lib/workspace-files-route.js +42 -14
  43. package/lib/workspace-files-wsl.js +25 -8
  44. package/package.json +34 -25
package/CHANGELOG.md CHANGED
@@ -16,6 +16,423 @@ were part of the record.
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.10.0] - 2026-10-18
20
+
21
+ The native in-distro agent is the default session-runtime backend: `agentBackend: auto`
22
+ selects the packaged static-musl binary for the distro's architecture, the shipped
23
+ shell agent stays as the fallback, and `native`/`shell` keep the strict and forced
24
+ modes. The peer line moves to DeepSeek Harness `0.2.1-rc.2`, which is the desktop
25
+ app's own version line. No module under `src/` changed, and that is the
26
+ finding, not an omission: every peer artifact this plugin subclasses or
27
+ replicates is byte-identical to the one shipped for `0.2.0-rc.2`, except one
28
+ added field in the `dsh-api-workspace-files` class that `WorkspaceFilesWsl`
29
+ extends (and that package's regenerated typert artifacts), so the seams the
30
+ code is built on did not move. What did move is the shipped composition — the
31
+ standard agent preset — and the plugin's embedded `preset-wsl` is regenerated
32
+ from it.
33
+
34
+ ### Added
35
+
36
+ - **The watcher uses inotify when it can.** With `inotifywait` installed and an
37
+ unbounded directory target, the in-distro watcher now uses recursive inotify
38
+ watches and coalesces events to one callback per 0.5s instead of polling
39
+ `find`. A file target, a positive `watchMaxDepth`, or a missing
40
+ `inotify-tools` keeps the polling fallback, so the backend is optional. The
41
+ polling mtime-old blind spot does not apply to the inotify path.
42
+
43
+ - **`sessionSandbox` runs one session's commands in one scope.** With the key
44
+ on, the shell executor acquires the runtime for `(distro, workspace,
45
+ policy)` on the first command, reuses it for the session, runs the command
46
+ WITHOUT the per-command bwrap wrap (the runtime's own mounts confine it),
47
+ and releases it from the preset scope's disposer — closing the agent and
48
+ stopping the unit, whose cgroup reaps the session's leftovers. On in the
49
+ shipped preset; requires `agent: true`, systemd as PID 1, a reachable user
50
+ manager, `systemd-run` and `Linger`. The preflight refuses with the remedy
51
+ when one is missing (only a success is cached, so following the remedy
52
+ needs no app restart); set `sessionSandbox: false` for the per-command
53
+ shape. Validated on a live session: `ps -p 1` is systemd, the command's
54
+ cgroup is the `dsh-wsl-*.scope`, a background process and `/tmp` survive
55
+ across commands, and the unit disappears after the session ends.
56
+
57
+ - **The session-runtime registry.** `src/session-runtime.ts` refcounts one
58
+ agent per `(distro, workspace, policy)`, composes the systemd user scope
59
+ prefix with a bwrap mount profile that avoids a PID namespace (and binds the
60
+ real `/tmp` under `workspace-write`), and tears a runtime down by closing the
61
+ agent and then stopping the scope. `--unshare-pid` is now a profile option
62
+ (default true), and the agent accepts an explicit lease independent of the
63
+ idle close. Tests cover the refcount, the mode-specific mounts, the mask in
64
+ the key, the explicit lease, and the close-then-stop order. It is wired into
65
+ the shell executor behind `sessionSandbox`, with a preflight that refuses
66
+ with a remedy; the filesystem provider's unification is next.
67
+
68
+ - **The session-scoped sandbox's launcher primitives.** `src/session-scope.ts`
69
+ builds the systemd user unit name, the `systemd-run --user --scope` prefix,
70
+ the stop argv and the preflight probe argv; unit tests pin the shapes and the
71
+ sanitization. ADR 0001 records the runtime-ownership decision the wiring
72
+ still has to make (the agent is a per-distro singleton today). No behavior
73
+ change yet.
74
+
75
+ - **`docs/VALUE.zh.md` states what the plugin is worth, in Chinese**, for a
76
+ reader deciding whether to install it: the three misalignments it removes,
77
+ six values each with its mechanism and the evidence that backs it, the
78
+ comparison against no integration / desktop remoting / putting the CLI in
79
+ WSL, the costs and limits stated before the sale, how the claims are
80
+ proven, and a rule for when not to use it. It is linked from both readme
81
+ indexes and sits beside the reference docs, never in place of them.
82
+
83
+ ### Changed
84
+
85
+ - **The peer pins move to the `0.2.1-rc.2` line**: `@deepseek-ai/dsh-*` at
86
+ `0.2.1-rc.2`, `@deepseek-ai/cordis` at `~4.0.5`, `@deepseek-ai/schemastery`
87
+ at `~3.18.5`, with `pnpm-lock.yaml` refreshed. pnpm's release-age policy
88
+ records the exact closure it had to exempt in `pnpm-workspace.yaml`, the way
89
+ the harness repository does. The three seam packages were compared byte for
90
+ byte against the previous pin: `dsh-fs-local`'s `lib/index.js` (the fsio
91
+ dialect this plugin replicates), `dsh-fs`'s, and `dsh-bash-local`'s (the
92
+ spill machinery and the `spawnSpec` seam) are identical files, so every line
93
+ reference in `docs/PEER-PARITY.md` and `docs/UPSTREAM-SPAWN-SEAM.md` still
94
+ names the same code — only the tarballs' digests changed, and
95
+ `docs/PEER-PARITY.md` now carries the `0.2.1-rc.2` ones. `cordis@4.0.5` and
96
+ `schemastery@3.18.5` likewise publish the same bytes as `4.0.4` and
97
+ `3.18.4` except for their manifests. Elsewhere: `dsh-fs@0.2.1-rc.2` drops its
98
+ unpublished `./invariant` export subpath (nothing here imported it),
99
+ `dsh-subprocess-local`'s new code is Linux systemd scope selection (this
100
+ plugin spawns `wsl.exe` from a Windows host), `dsh-api-workspace-files` adds
101
+ one `resource` field to its own lookup registration, and the typert protocol
102
+ gained modules.
103
+ - **`preset-wsl` is regenerated from the shipped standard preset.** The rows
104
+ that arrived are the clock-reading `time-context` row, `tool-schedule`, and
105
+ the disabled `tool-plugin-market/tools` row; the rows that left are
106
+ `tool-ralph` and the disabled `subagent_codex` / `subagent_claude_code`
107
+ entries, whose providers the preset no longer declares anywhere. The two
108
+ `tool-subagent` rows drop `backgroundMode`, the plan-mode section's first
109
+ sentence now says the plan is approved *through* `exit_plan_mode`, and
110
+ `persona.suffix` is gone: the host plane's new `working-directory` row
111
+ reports the Session's cwd, so the "Your working directory is …" suffix is no
112
+ longer the mechanism. The three deliberate deviations from the shipped copy
113
+ — the `wsl-env` realm wrapping every row, the fixed shell-tool choice, and
114
+ the preset's identity — are unchanged, and the transformation is now
115
+ [scripts/build-preset-wsl.mjs](scripts/build-preset-wsl.mjs) rather than the
116
+ machine-local generator the block used to name: it takes the shipped file and
117
+ prints the row, and its output is the committed block byte for byte.
118
+ - `docs/UPSTREAM-TERMINAL-FONT.md` closes: the `0.2.1-rc.2` line made the
119
+ terminal font a user setting (`--dsh-font-family-terminal` /
120
+ `--dsh-terminal-font-size`), so the hardcoded stack it argued against is now
121
+ only the fallback appended after the user's list.
122
+ `docs/UPSTREAM-TERMINAL-TITLE.md` was re-checked against the new tag and is
123
+ unchanged, including after the terminal controller's 0.2.1 rework: the tab
124
+ title is still built from `shell.name` alone.
125
+
126
+ ### Fixed
127
+
128
+ - **The GUI file service loads again from a linked install** (`workspace-files-wsl`
129
+ failed to import, and the Desktop host then died on a `write EOF`). The module
130
+ imports `@deepseek-ai/dsh-api-workspace-files` and
131
+ `@deepseek-ai/dsh-typert-protocol` at runtime, but both were declared only as
132
+ dev dependencies. The runtime's linked-plugin resolver routes a bare import
133
+ to the installation package only when the plugin's own manifest lists that
134
+ name under `peerDependencies` (`dsh-app-boot`'s `readPeerNames` /
135
+ `routeLinked`), so the import missed, the entry never activated, and Desktop
136
+ exited 1; the fatal `write EOF` is the shell writing to that already-exited
137
+ host. Both names are peers now, and `test/peers.test.mjs` scans every
138
+ `lib/*.js` import so an undeclared runtime package fails the check chain
139
+ instead of the app.
140
+
141
+ - **A warm native-agent cache no longer kills the Host on startup.** The
142
+ in-distro copy script prints the cache path and exits *without reading stdin*
143
+ when the digest already matches. `copyBinary` still streamed the packaged
144
+ binary into that closed pipe and left stdin's `error` event unhandled; on
145
+ Windows the write surfaced as `write EOF` and took the Host down before the
146
+ UI could load, so the first run (cold cache) worked and every later start
147
+ crashed. The write error is absorbed now — the exit code and stdout already
148
+ decide the result — and a POSIX regression check runs an early-exit script
149
+ against a payload larger than any pipe buffer.
150
+
151
+ - **The root-plane router survives its first distro target, and that path now
152
+ has a regression test.** Three defects sat behind the same runtime failure:
153
+ - `WslRoutingFileSystem` kept its state in ES `#private` fields. Cordis
154
+ invokes a service method with a shadow receiver (the traceable proxy
155
+ `ReflectService` builds), and private members do not cross it, so the first
156
+ routed call threw `Cannot read private member #distro from an object whose
157
+ class did not declare it`. The fields are plain now, exactly like the host
158
+ backend's own `config` and `locks`.
159
+ - Constructing the distro backend with the router's context re-registered
160
+ `fs` in the same scope (`service "fs" has been registered`). The backend
161
+ now receives an `fs`-isolated child scope, while `sandboxPolicy` still
162
+ resolves from the parent. The router captures its construction context,
163
+ because `this.ctx` inside a method is the *caller's* context, not its own.
164
+ - The row dropped the `static inject = ["sandboxPolicy"]` declaration the
165
+ shipped `fs-sandbox` row carries, although the backend it constructs reads
166
+ `ctx.sandboxPolicy.defaultMode`. That service is provided by a sibling row,
167
+ not an ancestor, so the fiber could not resolve it and construction failed
168
+ with `cannot get property "sandboxPolicy" without inject`. The declaration
169
+ is restored.
170
+
171
+ The `0.2.1-rc.2` line's new `working-directory` row resolves the session cwd
172
+ through the root `ctx.fs`, which is what first exercised the distro branch.
173
+ `test/composition.test.mjs` now boots the takeover row through the Loader
174
+ and drives the whole routing table through the service proxy — distro side
175
+ stubbed at the backend surface, host side over a temp workspace, with the
176
+ policy supplied from a sibling fiber as in the app — so all three defects
177
+ fail the unit suite instead of a session.
178
+
179
+ - **The GUI file service confines every read to the workspace, not just
180
+ listings.** `stat`, `read` and `readBytes` passed the joined Linux path
181
+ straight to `stat`/`sed`/`tail`, so an absolute path outside the workspace,
182
+ a `..` traversal, a symlink leaving it, or another distro's UNC name were
183
+ served — while `list` already resolved the real path and refused escapes
184
+ with `workspace-file/outside-workspace`. All four ops now share one guard:
185
+ `realpath -L` on the target and the root, then the same root/under-root
186
+ test, so an escape is the same refusal whichever op asks. The new checks
187
+ drive the real `sh` guard against a temp root (absolute sibling, `..`,
188
+ symlink out, symlink in, the root itself) and assert the refusal mapping in
189
+ all three ops.
190
+
191
+ - **The resident transport owns its generation and its pipes.** Three
192
+ lifecycle leaks shared one cause — state keyed by "the current process"
193
+ instead of by the process that actually emitted the event:
194
+ - Every spawn now owns a generation; its `error`, `exit`, `stdout` and
195
+ `stderr` handlers return unless `this.process` is still that child, so a
196
+ stale exit can no longer settle a newer start, null a newer transport, or
197
+ let a stale watchdog kill it. `handshakeTimer`, the ACK-armed watchdog and
198
+ the frame-cap kill all bind to their own child.
199
+ - `close()` settles the requests it interrupts, fails a start it interrupts,
200
+ and destroys the host's ends of `stdin`/`stdout`/`stderr` after the exit.
201
+ A descendant that inherited a pipe's write end can no longer keep the
202
+ read side — and the fd — open until it exits.
203
+ - The agent's lease and timeout watchdogs now spawn with `/dev/null` stdio,
204
+ so their `sleep` children do not hold the protocol pipes after the agent
205
+ exits. Without this, killing the watchdog subshell left the `sleep` as an
206
+ orphan holding the write ends for up to the full lease window (12 minutes
207
+ at the shipped 720 s), which is what kept a test process alive after every
208
+ check had already passed.
209
+
210
+ The fake transport covers the stale-exit and stdio-teardown checks; the real
211
+ `sh` agent is spawned with a 720 s lease and asserted to close its pipes
212
+ immediately after `SHUTDOWN`.
213
+
214
+ - **Cancellation reaches the command the agent is blocked on.** The stdin loop
215
+ is serial: a `KILL` frame written while a command ran sat in the pipe until
216
+ that command finished, and by then the id no longer matched, so the frame
217
+ was dropped. A request aborted mid-flight kept running to its own timeout; a
218
+ request aborted while queued ran in full later, with its answer discarded as
219
+ noise. Protocol 5 adds the agent shell's PID to `HELLO`; on abort the host
220
+ delivers `SIGUSR1` from a second `wsl.exe`, and the agent's trap kills the
221
+ request's process group — TERM, then KILL after the same 3 s grace the
222
+ timeout watcher uses. A request cancelled while it was still queued stays in
223
+ the pending map and is signalled on its `ACK`, so it is killed the moment it
224
+ becomes the running one; its late `RES` only cleans up. A PID-namespaced
225
+ confined resident is never signalled by its HELLO pid (that PID does not
226
+ exist in the distro and could name an unrelated process) — there the KILL
227
+ frame remains the fallback, and filesystem ops are short by construction.
228
+ The `KILL` frame stays as the fallback everywhere.
229
+
230
+ The fake transport covers the started, queued and confined cases; the real
231
+ `sh` agent is asked to run `sleep 30`, receives `USR1` after its ACK, and is
232
+ asserted to answer with a non-zero exit well before the sleep ends.
233
+
234
+ - **The agent's ACK is emitted only once the request's child exists.** It used
235
+ to be printed just before `run_capture`, i.e. before `CURRENT_PID` was set.
236
+ The host arms its execution watchdog and delivers `USR1` on the ACK, so both
237
+ could race the spawn: a cancellation right after the ACK found an empty
238
+ `CURRENT_PID`, dropped the signal, and the command ran to its own timeout.
239
+ `run_capture` now prints the ACK after `CURRENT_PID=$!`, which is what the
240
+ host's comment always claimed it meant.
241
+
242
+ - **The GUI page contract no longer returns its EOF probe line.** `pageArgv`
243
+ reads one line past the requested page so EOF is decidable from the yield;
244
+ `distroRead` returned every line it got and derived `lines`/`eof` from the
245
+ probe, so an N-line page carried N+1 lines, a page ending exactly at the file
246
+ reported `eof: false`, and `bytes` added the line count on top of the UTF-8
247
+ length (over-charging CRLF files and refusing pages that fit). The probe is
248
+ split off before counting, `eof` means "no line past the page", and `bytes`
249
+ is the page's own UTF-8 length. Tests cover truncation, exact EOF, and the
250
+ byte cap.
251
+
252
+ - **The resident's pending queue is bounded.** One resident refuses a request
253
+ past `maxPending` (64 by default) with a clear "requests in flight" error
254
+ instead of growing its map — and the retained frame lines — without bound.
255
+ A single frame's size is still the caller's.
256
+
257
+ - **The output budget now bounds the capture while the command runs.** The
258
+ agent wrote stdout and stderr to two unbounded files and applied
259
+ `maxOutputBytes` only while encoding the `RES`, so a chatty command could
260
+ fill the distro's `/tmp` — and a huge answer could exceed the 256 MiB frame
261
+ cap and kill the shared agent. With a budget, both streams are captured
262
+ through FIFOs drained by a bounded `dd` reader; the rest is discarded, so
263
+ the files stay within a block or two of the budget. The readers are given a
264
+ one-second grace once the request's own process is gone and then stopped, so
265
+ a background descendant holding the FIFO cannot delay the answer — and the
266
+ grace lets a just-exited command's output reach the file before the reader
267
+ is stopped. The block slack is what distinguishes "exactly at the cap" from
268
+ "cut". The unbounded path (`FS` frames) still writes files directly. Tests
269
+ cover `yes` capped at 1 KiB (`truncated: true`) and `sleep & echo` answered
270
+ without waiting for the background child.
271
+
272
+ - **Confined file mutations share the session runtime.** With `wsl-fs`
273
+ `sessionSandbox: true` (the shipped preset), a confined `writeText`/`editText`
274
+ acquires the same `(distro, workspace, policy)` runtime as the shell
275
+ executor: one agent, one mount profile, one real `/tmp`, and the same scope
276
+ lifecycle. Reads stay on the plain shared agent; the app-level `fs-routing`
277
+ backend keeps the mode off because it is not session-owned. A failed
278
+ preflight refuses the write with the systemd/linger remedy.
279
+
280
+ - **A closed watcher signals its loop by PID.** The heartbeat alone cannot
281
+ rely on the pipe closing when the host kills `wsl.exe`, so the loop now
282
+ announces its shell PID (`P <pid>`) before READY and `close()` sends it
283
+ `TERM` through a second `wsl.exe`; the TERM trap removes the stamp. The
284
+ real-machine watch probe now asserts the loop is gone, matching the script's
285
+ own argv rather than the `pgrep` wrapper. This is the fix the probe found
286
+ when the heartbeat-only version was tried on the real transport.
287
+
288
+ - **A failed port scan keeps the last successful snapshot.** The ports
289
+ environment fact was set to an empty list on any scan failure, which the
290
+ model reads as "nothing is listening". A failed scan (agent out, non-zero
291
+ exit) now leaves the previous list in place and logs a debug line; only a
292
+ successful empty scan produces an empty list.
293
+
294
+ - **The stale-scope sweep recognizes the native agent.** It looked only for
295
+ `wsl-agent.sh` in the scope's cgroup, so a live native session scope (whose
296
+ agent is `dsh-wsl-agent`) could be stopped at the next session preflight.
297
+ The liveness check now accepts either marker; a scope with only leftover
298
+ background processes is still reaped.
299
+
300
+ - **The native EXEC timeout is seconds.** The spike read the v5 EXEC frame's
301
+ timeout field as milliseconds, so a 120 s budget killed every command after
302
+ 120 ms; longer commands exited 143 on the native session. It now converts
303
+ seconds (`Duration::from_secs`), and the spike's check runs a 300 ms command
304
+ under a 2 s budget so the unit cannot regress silently.
305
+
306
+ - **The inotify watcher is reaped promptly on close and on a vanished
307
+ directory.** The blocking FIFO read is woken by a one-second marker, so the
308
+ TERM trap runs and the target check reports `E` within a second of the
309
+ directory disappearing. The real-machine probe's close and vanished-target
310
+ checks now pass with `inotify-tools` installed; the pre-watch creation check
311
+ was also corrected (a file created before the watch is not the polling
312
+ mtime blind spot).
313
+
314
+ - **A closed watcher no longer leaks its distro-side loop.** `close()` kills
315
+ `wsl.exe` but cannot signal the loop inside the distro, and a loop that only
316
+ writes on changes would sleep and poll forever. Each tick now writes an
317
+ ignored `H` heartbeat; a `PIPE` trap turns the closed pipe's SIGPIPE into a
318
+ clean exit through the stamp-removing EXIT trap. The host ignores `H`; the
319
+ real-`sh` check closes the pipe and asserts the loop exits and the stamp is
320
+ gone.
321
+
322
+ - **A distro target contains only targets in the same distro.** `contains()`
323
+ compared Linux path strings, so another distro's share (or a `..`-bearing
324
+ key) could test as contained when the spelling lined up. It now compares the
325
+ distro coordinate and lexically normalizes the paths; the resolver remains
326
+ the canonicalizer for symlinks.
327
+
328
+ - **A `notdir` failure reports `FS_NOT_DIRECTORY`.** The wire reason was
329
+ mapped to `FS_NOT_FOUND`, so a path whose parent is a regular file looked
330
+ like a missing path. It now carries the specific code the tool layer already
331
+ knows, so the model gets the right remedy.
332
+
333
+ - **Stale session scopes are swept.** A hard kill of the app can leave a
334
+ `dsh-wsl-*.scope` and its background processes behind. The session preflight
335
+ now lists those units and stops one only when its cgroup has no
336
+ `wsl-agent.sh`; a live agent means another session still owns it. The sweep
337
+ runs once per distro per process (a failed spawn re-arms it) and is a no-op
338
+ without a user manager. Validated on a live user manager: two
339
+ `dsh-wsl-test-sweep*` scopes with only a `sleep` were stopped while the
340
+ session's own scope (with its live agent) stayed active.
341
+
342
+ - **`watch()` accepts a file target.** The poll loop treated every
343
+ non-directory as vanished, so `ctx.fs.watch` on a regular file failed
344
+ immediately with "no longer exists". A file is now watched by its
345
+ `mtime:size` fingerprint — a rename-over or an in-place write fires — and a
346
+ removal reports the target as gone. Directory watching is unchanged. A
347
+ real-`sh` check runs the loop against a temp file (READY, a size change, a
348
+ removal).
349
+
350
+ ### Notes
351
+
352
+ - **The native-session probe.** `test/probe/native-session.mjs` (also in the
353
+ real-machine suite and the self-hosted probes job) exercises the packaged
354
+ native binary end to end: the cache/digest, the session scope, EXEC, FS
355
+ write/read, a KILL that reaps the request group while the agent survives, a
356
+ wrong HELLO digest failing the handshake, and the scope/cache teardown. It
357
+ is 8/8 on the live machine. The probe found two integration bugs, both
358
+ fixed here: a valid cache is now reused instead of rewritten (rewriting a
359
+ binary a live session is running fails with ETXTBSY), and the agent's stdin
360
+ now has an error listener so `close()` after a failed handshake (EPIPE) does
361
+ not crash the host.
362
+
363
+ - **Static musl packages for x86_64 and aarch64.** The package now ships
364
+ `agent/native/linux-x86_64/dsh-wsl-agent` (static-pie musl, 635,752 bytes,
365
+ sha256 `5920bdd5…`) and `agent/native/linux-aarch64/dsh-wsl-agent` (static
366
+ musl, 535,136 bytes, sha256 `cf7dd150…`), both stripped. One static binary
367
+ runs on glibc and musl distros, so Alpine no longer depends on the shell
368
+ fallback. A CI job builds both (x86_64 via musl-gcc, aarch64 via
369
+ cargo-zigbuild), tests the x86_64 build and uploads the `native-package`
370
+ artifact; the committed binaries are the reviewed result.
371
+
372
+ - **The native HELLO binds the process to the binary.** The native agent
373
+ hashes `/proc/self/exe` and puts the sha256 in its HELLO digest; the host
374
+ passes the digest it copied into the cache as `expectedDigest`, so a binary
375
+ swapped after the copy check fails the handshake before accepting a request.
376
+ The spike is 18/18 (the new check compares the handshake to the file's
377
+ sha256). The shell agent's script digest is unchanged.
378
+
379
+ - **The packaged native binary.** `agent/native/linux-x86_64/dsh-wsl-agent`
380
+ ships in the npm `files`; `agentBackend: auto` resolves it for a distro whose
381
+ `uname -m` is x86_64/amd64 (aarch64 is mapped but has no binary yet), with
382
+ `DSH_WSL_AGENT_NATIVE` still the development override. After the sha256 copy
383
+ the host executes the binary once with `</dev/null` and only then selects it,
384
+ so a glibc/musl mismatch falls back to the shell agent. The CI builds the
385
+ spike from source and runs its suite against both that build and the
386
+ committed package binary. The musl and aarch64 builds are the next step.
387
+
388
+ - Added **`wsl-shell.agentBackend`**: `auto` (the default) uses the native
389
+ in-distro agent when a verified source is available — the development
390
+ override `DSH_WSL_AGENT_NATIVE`, and later the packaged per-arch binary —
391
+ else the shipped shell agent; `native` fails the first command instead of
392
+ falling back; `shell` forces the shell agent. Commands and confined writes
393
+ share the session runtime, so both follow the selection. With no source and
394
+ the default, behavior is unchanged (shell).
395
+
396
+ - Added the opt-in **native agent host wiring**: `DSH_WSL_AGENT_NATIVE=<host
397
+ path>` makes the session runtime sha256 the binary into
398
+ `~/.cache/dsh-wsl/agent/<sha>/dsh-wsl-agent` (never from `/mnt/c`), verify
399
+ the distro-side digest, and spawn it under the session scope/bwrap with USR1
400
+ off (the native reads KILL frames). The shared FS-read agent, the one-shot
401
+ fallback and the default remain the shell agent; only x86_64 is accepted.
402
+ `scripts/native-wiring-check.mjs` verifies the copy + real runtime + EXEC/FS
403
+ end to end. Packaging/musl/default rollout is still pending.
404
+
405
+ - Added the **native agent spike** (ADR 0002 stage 2) under
406
+ `agent/native/spike`: a std-only Rust event loop for HELLO/PING/SHUTDOWN/
407
+ SETENV/EXEC/KILL with per-request process groups, plus the FS
408
+ `stat`/`lstat`/`read`/`write`/`list`/`realpath` surface (mode, no-replace,
409
+ expected version, staging + fsync + atomic publication; the `dsh-fs` side
410
+ channel in the RES stderr). Its manual check passes 16/16 (concurrent EXECs,
411
+ KILL without a second `wsl.exe`, timeout group kill, output cut with the pipe
412
+ still drained, FS stat/lstat/read/list/realpath matching the shell,
413
+ write/refusal, side channel, dot-dot refusal, expected-version stale replace,
414
+ setuid mode, EXEC differential). The host wiring remains; the shell agent
415
+ stays the shipped peer.
416
+
417
+ - Added **ADR 0002 — native in-distro agent (design)**: goals and non-goals,
418
+ language/distribution options (Rust static binaries, copied to the user's
419
+ cache), the v5-compatible spike and v6 concurrency protocol, the event-loop
420
+ process model, the host integration/copy/verify path, the security and test
421
+ matrix, the migration stages, and the triggers that would justify building
422
+ it. Implementation is deferred; the shell agent remains the shipped peer.
423
+
424
+ - Documented the one-shot fallback's timeout boundary: the host deadline kills
425
+ `wsl.exe`, not the distro-side tree; the session runtime's scope is the
426
+ cleanup path, and a systemd-scoped one-shot is not shipped.
427
+
428
+
429
+ - Gates on the new pins: style 9/9, syntax 7/7 members and 3/3 chain members
430
+ against `schemastery@3.18.5`, build freshness 74 artifacts, `tsc --noEmit`
431
+ clean, unit suite 30/30 files, coverage 98.42% lines / 84.20% branches /
432
+ 96.01% functions. The real-machine probes were not re-run: they need
433
+ Windows and WSL, and their peer-verify legs are covered by the byte
434
+ comparison above.
435
+
19
436
  ## [0.9.0] - 2026-10-06
20
437
 
21
438
  A usability round over the sandbox failure path: the failure stops being a
package/CONTRIBUTING.md CHANGED
@@ -24,8 +24,8 @@ algorithm, and the boot test starts a row through the real Loader — so a fresh
24
24
  checkout needs one `pnpm install` before the gates pass.
25
25
 
26
26
  Those pins are deliberate, and they move with the harness rather than on their
27
- own: the `@deepseek-ai/dsh-*` peers sit at `0.2.0-rc.2`, `@deepseek-ai/cordis`
28
- at `~4.0.4` and `@deepseek-ai/schemastery` at `~3.18.4`. The plugin subclasses
27
+ own: the `@deepseek-ai/dsh-*` peers sit at `0.2.1-rc.2`, `@deepseek-ai/cordis`
28
+ at `~4.0.5` and `@deepseek-ai/schemastery` at `~3.18.5`. The plugin subclasses
29
29
  peer classes and mirrors peer protocol, so raising one pin alone is how the
30
30
  seams drift: bump them in the commit that matches a harness release, re-run the
31
31
  comparison `docs/PEER-PARITY.md` describes, and let the gate chain and the