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/cordis.patch.yml CHANGED
@@ -187,9 +187,20 @@
187
187
  # Because a preset's plugins mount under a REGISTRY-owned scope (not this row's
188
188
  # ctx), the realm has to be declared here, inside config.plugins.
189
189
  #
190
- # Generated from the shipped standard preset by build-preset-wsl.mjs so the
191
- # embedded plan-mode section and every nested group survive byte-for-byte.
192
- # Re-run it after an app update that changes the preset.
190
+ # Generated from the shipped standard preset so the embedded plan-mode
191
+ # section and every nested group survive byte-for-byte: this copy is
192
+ # `packages/bundle/web-app/presets/standard.patch.yml` of the
193
+ # `dsh-v0.2.1-rc.2` source tree, changed in exactly three ways —
194
+ # 1. every shipped row mounts inside `wsl-env`, so the preset's tools see
195
+ # the distro's shell and fs (the realm is declared on the group);
196
+ # 2. `tool-bash` is enabled and `tool-pwsh` disabled outright, where the
197
+ # shipped copy derives the choice from `process.platform`;
198
+ # 3. the identity: row `preset-wsl`, preset `wsl`, `order: 2`.
199
+ # Regenerate it when an app update changes the preset: take
200
+ # `packages/bundle/web-app/presets/standard.patch.yml` from the harness tree
201
+ # the peer pins track and run `node scripts/build-preset-wsl.mjs <that
202
+ # file>`, then replace this row to the end of the file. The script applies
203
+ # changes 1 and 2 itself, so only change 3 is yours.
193
204
  # ═════════════════════════════════════════════════════════════════════════════
194
205
  - id: preset-wsl
195
206
  name: '@deepseek-ai/dsh-agent-preset'
@@ -209,12 +220,15 @@
209
220
  # asked of the distro itself. Pin a directory here to override it —
210
221
  # a value that is right on one machine is wrong on every other.
211
222
  #
212
- # Each command is confined inside the distro by bubblewrap, in the
213
- # policy's mode, and the file tools are fenced by the same policy.
214
- # Install bubblewrap there (`sudo apt install bubblewrap`); without
215
- # it the providers fail closed rather than run unconfined. Add
216
- # `sandbox: false` to either provider for the old, unconfined
217
- # posture.
223
+ # Commands run in one persistent systemd user scope per session
224
+ # (ADR 0001): a background dev server survives the command that
225
+ # started it, `/tmp` is the distro's real `/tmp`, and `ps` sees the
226
+ # session. This needs systemd as PID 1 (`[boot] systemd=true` in
227
+ # /etc/wsl.conf) and a lingering user manager (`loginctl
228
+ # enable-linger <user>`); the preflight refuses with those remedies
229
+ # when one is missing. Set `sessionSandbox: false` for the old
230
+ # per-command shape. Bubblewrap is still required for the mount
231
+ # profile; without it the providers fail closed.
218
232
  - id: wsl-shell
219
233
  name: 'dsh-plugin-wsl-env'
220
234
  config:
@@ -222,6 +236,13 @@
222
236
  loginShell: true
223
237
  timeoutMs: 120000
224
238
  maxTimeoutMs: 600000
239
+ sessionSandbox: true
240
+ # auto: the native session-runtime agent when a verified
241
+ # source is available (packaged binary, or the
242
+ # DSH_WSL_AGENT_NATIVE development override); otherwise the
243
+ # shipped shell agent. native fails the first command instead
244
+ # of falling back; shell forces the shell agent.
245
+ agentBackend: auto
225
246
  - id: wsl-fs
226
247
  name: 'dsh-plugin-wsl-env/fs'
227
248
  config:
@@ -234,15 +255,19 @@
234
255
  # share: the opt-out — the Windows-side host fs stack over
235
256
  # the 9p share.
236
257
  substrate: agent
258
+ # Confined writes ride the session runtime (ADR 0001), so they
259
+ # share the command's real /tmp and workspace mounts.
260
+ sessionSandbox: true
237
261
  - id: persona
238
262
  name: '@deepseek-ai/dsh-persona'
239
263
  config:
240
- suffix: Your working directory is {{cwd}}.
241
264
  prefix: You are a coding agent powered by the {{model}} model.
242
265
  - id: agent-instructions
243
266
  name: '@deepseek-ai/dsh-agent-instructions'
244
267
  config:
245
268
  maxBytes: 65536
269
+ - id: time-context
270
+ name: '@deepseek-ai/dsh-time-context'
246
271
  - id: tool-bash
247
272
  name: '@deepseek-ai/dsh-tool-bash'
248
273
  disabled: false
@@ -257,6 +282,8 @@
257
282
  sampleOverCapGlobResults: false
258
283
  - id: tool-jobs
259
284
  name: '@deepseek-ai/dsh-tool-jobs'
285
+ - id: tool-schedule
286
+ name: '@deepseek-ai/dsh-tool-schedule'
260
287
  - id: skill-filesystem
261
288
  name: '@deepseek-ai/dsh-skill-filesystem'
262
289
  - id: tool-skill
@@ -275,7 +302,7 @@
275
302
  name: '@deepseek-ai/dsh-plan-mode'
276
303
  config:
277
304
  section: |
278
- You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
305
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
279
306
 
280
307
  Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
281
308
 
@@ -319,41 +346,17 @@
319
346
  provider: spawn
320
347
  toolName: subagent
321
348
  modelSelectionSettings: true
322
- backgroundMode: continuable
323
349
  - id: tool-subagent-fork
324
350
  name: '@deepseek-ai/dsh-tool-subagent'
325
351
  config:
326
352
  provider: fork
327
353
  toolName: subagent_fork
328
- backgroundMode: continuable
329
- - id: tool-subagent-codex
330
- name: '@deepseek-ai/dsh-tool-subagent'
331
- disabled: true
332
- config:
333
- provider: codex
334
- toolName: subagent_codex
335
- backgroundMode: one-shot
336
- maxDepth: provider-managed
337
- - id: tool-subagent-claude-code
338
- name: '@deepseek-ai/dsh-tool-subagent'
339
- disabled: true
340
- config:
341
- provider: claude-code
342
- toolName: subagent_claude_code
343
- backgroundMode: one-shot
344
- maxDepth: provider-managed
345
354
  - id: workflow-ptc
346
355
  name: '@deepseek-ai/dsh-workflow-ptc'
347
356
  config:
348
357
  provider: spawn
349
358
  - id: tool-workflow
350
359
  name: '@deepseek-ai/dsh-tool-workflow'
351
- - id: tool-ralph
352
- name: '@deepseek-ai/dsh-tool-ralph'
353
- disabled: true
354
- config:
355
- subagentProvider: spawn
356
- maxRounds: 64
357
360
  - id: tool-ask-user
358
361
  name: '@deepseek-ai/dsh-tool-ask-user'
359
362
  - id: tool-todo
@@ -370,3 +373,6 @@
370
373
  - id: tool-plugin-manager
371
374
  name: '@deepseek-ai/dsh-plugin-manager/tools'
372
375
  disabled: true
376
+ - id: tool-plugin-market
377
+ name: '@deepseek-ai/dsh-plugin-market/tools'
378
+ disabled: true
@@ -198,6 +198,14 @@ wsl.exe -d <distro> --cd <linux dir> --exec bwrap \
198
198
  [--tmpfs /tmp --bind <workspace> <workspace>] [--tmpfs /mnt] -- <shell> -lc <cmd>
199
199
  ```
200
200
 
201
+ By default the profile wraps the session's resident agent ONCE, under
202
+ `systemd-run --user --scope`, and the command argv is the shell line itself:
203
+ `bwrap … --die-with-parent --bind /tmp /tmp --bind <workspace> <workspace> -- sh agent.sh`.
204
+ There is no `--unshare-pid` and no per-command tmpfs, so background processes
205
+ survive, `/tmp` is the distro's real `/tmp`, and `ps` sees the session. The
206
+ per-command argv above is what `sessionSandbox: false` (and the one-shot
207
+ fallback) still runs.
208
+
201
209
  These are the same arguments DSH's Linux runner uses (`dsh-sandbox-local`), so the
202
210
  behaviour and the error messages match a Linux host. The function
203
211
  `bwrapProfileArgs` in [`lib/bwrap.js`](../lib/bwrap.js) builds them — the
@@ -211,7 +219,7 @@ the row keys, and `lib/sandbox-core.js` states its honest ceiling.
211
219
  | Mode | What a command inside the distro can do |
212
220
  |---|---|
213
221
  | `read-only` | read the whole distro; a fresh `/dev` is mounted writable, so `/dev/null` and `/dev/shm` work, and nothing else does |
214
- | `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
222
+ | `workspace-write` | the above, plus the session workspace is writable; `/tmp` is the distro's real `/tmp` in the session runtime, and a fresh tmpfs in the per-command profile (`sessionSandbox: false`) |
215
223
  | `danger-full-access` | no sandbox; used for an approved wider-permission request |
216
224
 
217
225
  `WslFileSystem` checks writes against the same policy and the same writable list,
@@ -26,6 +26,8 @@ has its own table at the end of this page.
26
26
  | | `shell` | `''` | pin a shell inside the distro; empty means the user's login shell |
27
27
  | | `loginShell` | `true` *(shipped)* | use `<shell> -lc`, which sources your profile, instead of a bare `-c`. Applies only to the POSIX family; a non-POSIX shell (fish, csh) still gets a bare `-c` |
28
28
  | | `agent` | `true` | run commands over the resident in-distro agent (one long-lived process; in-distro timeout); the one-shot `wsl.exe` path is fallen back to only when the agent is out of service. Anything else — a cwd refusal, a sandbox refusal — is the command's own verdict and is raised, not retried on the other path |
29
+ | | `sessionSandbox` | `true` *(shipped)* | 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). On in the shipped preset; set `false` for the per-command shape |
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 |
29
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) |
30
32
  | | `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` |
31
33
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
@@ -34,15 +36,16 @@ has its own table at the end of this page.
34
36
  | | `restrictToDistro` | `true` *(shipped)* | refuse a path in **another distro**'s share. `/mnt/c` is a directory *inside* the pinned distro, so it is not affected. The refusal carries `FS_OUTSIDE_DISTRO`; that is not a sandbox decision, so the file tools do not offer a wider permission for it. Set `false` to allow another distro's share |
35
37
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy. Needs a usable `bwrap` in the distro too, and fails closed the same way: a distro without one refuses confined mutations (`FS_IO_ERROR`, with the install guidance) rather than downgrading them |
36
38
  | | `maskWindowsDrive` | `false` | as on `wsl-shell`: confined fs mutations publish through a resident whose profile shadows `/mnt` (same honest limits) |
39
+ | | `sessionSandbox` | `true` *(shipped)* | run confined mutations in the same session runtime the shell uses for the same `(distro, workspace, policy)` — one agent, one real `/tmp`, one workspace mount view; requires the same systemd/linger setup and the preflight refuses with the remedy. Set `false` for the per-policy confined resident. The app-level `fs-routing` backend is not session-owned and keeps this off |
37
40
  | | `substrate` | `agent` *(shipped)* | which I/O substrate serves the file tools. Only the resident in-distro agent (`lib/fs-substrate.js`) exists: reads, writes and identities run on ext4 — native symlinks, native mode bits, and the mutation guard survives to the write's atomic publication, kernel-enforced by the confined resident when the policy is confined. The former `share` opt-out (the Windows-side host stack over the 9p share) is refused at boot; delete the line. The comment shipped beside this key in `cordis.patch.yml` still calls `share` the opt-out — that comment is out of date, and the boot refusal is what runs |
38
41
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
39
- | | `watchMaxDepth` | `0` | how deep the in-distro watch scan walks; `0` walks the whole tree. Bound it (e.g. `4`) when the watched tree holds a `node_modules` — a change below the bound is seen only when another change above it fires the same callback |
42
+ | | `watchMaxDepth` | `0` | how deep the in-distro watch scan walks; `0` walks the whole tree and enables the inotify backend. A positive value keeps the polling scan (inotify cannot bound its walk); a change below the bound is seen only when another change above it fires the same callback |
40
43
  | `fs-routing` | `distro` | `''` *(shipped)* | which distro the root-plane filesystem routes to; empty resolves WSL's default. **A row of its own**, with its own copy of the distro knobs — changing `wsl-fs` does not change this one, nor the other way round |
41
44
  | | `wslPath` | `wsl.exe` *(shipped)* | path to `wsl.exe`; used here only to resolve the default distro |
42
45
  | | `sandbox` | `true` *(shipped)* | fence routed mutations by `ctx.sandboxPolicy`; same `bwrap` requirement and fail-closed refusal as `wsl-fs`. The routing row reports no `sandboxMode` — `undefined` whether the fence is on or off |
43
46
  | | `maskWindowsDrive` | `false` *(shipped)* | as on `wsl-shell`, for routed mutations |
44
47
  | | `restrictToDistro` | `true` *(shipped)* | refuse a path in another distro's share (`FS_OUTSIDE_DISTRO`) |
45
- | | `watchMaxDepth` | `0` *(shipped)* | how deep the routed watch scan walks; `0` walks the whole tree |
48
+ | | `watchMaxDepth` | `0` *(shipped)* | how deep the routed watch scan walks; `0` walks the whole tree and enables the inotify backend; a positive value keeps the polling scan |
46
49
  | | `distroCwd` | `''` | the routed backend's default workdir; empty means the distro user's home. `cwd` belongs to the host backend only — the distro side reads `distroCwd` — while `diffBasisMaxBytes` is shared: it bounds the overwrite diff on both sides |
47
50
  | `workspace-files-wsl` | `maxBytes` | `2097152` *(shipped)* | byte cap on one page of a text read, and the default byte window of a byte read |
48
51
  | | `maxFileBytes` | `33554432` *(shipped)* | full-file byte cap for the byte reader |
@@ -138,7 +141,7 @@ above. They all keep their defaults:
138
141
  |---|---|---|---|
139
142
  | `wsl-shell` (in `preset-wsl`) | `wslPath` | `wsl.exe` | path to `wsl.exe`, for an installation that is not on `PATH` |
140
143
  | | `hostCwd` | unset → `%SystemRoot%` | the Windows directory the `wsl.exe` process itself starts in |
141
- | | `forwardEnv` | `[]` | extra host environment variable names to forward into the distro through `WSLENV`. This is the one key here that widens what crosses the boundary: `NO_COLOR`, `TERM`, `PAGER`, `GIT_PAGER`, `LANG`, `LC_ALL` and every managed `DSH_*` name ride along without it, and `PATH` is deliberately never forwarded |
144
+ | | `forwardEnv` | `[]` | extra host environment variable names to forward into the distro through `WSLENV`. This is the one key here that widens what crosses the boundary: `NO_COLOR`, `TERM`, `PAGER`, `GIT_PAGER`, `LANG`, `LC_ALL` and every managed `DSH_*` name ride along without it, and `PATH` is deliberately never forwarded. It applies to the **one-shot `wsl.exe` path**; the resident agent's environment stays pinned to the managed `DSH_*` namespace and deliberately does not forward these names, so a secret listed here does not widen every model command in the session |
142
145
  | `wsl-fs` (in `preset-wsl`) | `wslPath` | `wsl.exe` | as above; used only to resolve the default distro |
143
146
  | | `diffBasisMaxBytes` | `10485760` | UTF-8 byte limit per side of an overwrite diff; a larger file publishes without a diff |
144
147
  | `fs-routing` | `cwd`, `diffBasisMaxBytes` | `process.cwd()`, `10485760` | `cwd` configures the host side only (the distro side reads `distroCwd`); `diffBasisMaxBytes` is shared by both — it also bounds the routed backend's overwrite diff |
@@ -226,4 +229,4 @@ behind the fail-closed choices above are in [LIMITATIONS.md](LIMITATIONS.md).
226
229
  | Key | Default | Meaning |
227
230
  | --- | --- | --- |
228
231
  | `distro` | `''` | the distro the `DSH_WSL_*` facts describe |
229
- | `portsRefreshMs` | `10000` | how often the `DSH_WSL_PORTS` listening-port snapshot refreshes through the resident agent |
232
+ | `portsRefreshMs` | `10000` | how often the `DSH_WSL_PORTS` listening-port snapshot refreshes through the resident agent. A failed scan keeps the last successful list (and a debug line), so a transient agent outage does not read as “no ports”; only a successful empty scan means none were listening |
@@ -71,14 +71,20 @@ crosses the 9p share. What to know about the one substrate there is:
71
71
  when `bubblewrap` itself is missing, so a distro that has bwrap but not rg
72
72
  needs the printed line run by hand. A search whose directory is a Windows
73
73
  folder keeps the host binary, which is native there.
74
- - **`watch()` is one poll loop** (a `find -newer` scan inside the distro), so
75
- the coarse-invalidation caveats below apply. An inotify backend is planned,
76
- not shipped.
77
- - **A confined command's `/tmp` is not the write tool's `/tmp`.** `workspace-write`
78
- mounts a fresh tmpfs at `/tmp` for the command — that is what makes it ephemeral —
79
- while the file tools' fence grants the distro's *real* `/tmp`. Both are writable, so
80
- neither side is refused, but a file the write tool puts in `/tmp` is invisible to
81
- `bash` and vice versa. Hand a file between them through the session workspace.
74
+ - **`watch()` prefers inotify, with a polling fallback.** When `inotifywait`
75
+ is installed and the target is an unbounded directory, the watcher uses
76
+ recursive inotify watches and coalesces events to one callback per 0.5s.
77
+ Otherwise — a file target, a positive `maxDepth`, or no `inotify-tools` — it
78
+ is the `find -newer` poll loop (a `stat` fingerprint for a file), so the
79
+ coarse-invalidation caveats below apply.
80
+ - **With `sessionSandbox: false`, a confined command's `/tmp` is not the write
81
+ tool's `/tmp`.** `workspace-write` mounts a fresh tmpfs at `/tmp` for the
82
+ command — that is what makes it ephemeral — while the file tools' fence grants
83
+ the distro's *real* `/tmp`. Both are writable, so neither side is refused, but a
84
+ file the write tool puts in `/tmp` is invisible to `bash` and vice versa. Hand
85
+ a file between them through the session workspace. The shipped preset turns
86
+ the session runtime on, and `wsl-fs` shares it for confined writes, so
87
+ commands and writes bind the same real `/tmp` and workspace.
82
88
  - A distro that is stopped or unregistered while the app runs takes the file
83
89
  tools' substrate with it: reads and writes report the substrate-unavailable
84
90
  error naming the cause. A death with nothing in flight costs nothing — the
@@ -87,20 +93,49 @@ crosses the 9p share. What to know about the one substrate there is:
87
93
  rebuild, and when that rebuild cannot complete — the distro is still down, or
88
94
  gone — the resident is permanently out: every later file operation fails the
89
95
  same way until the app restarts.
90
- - `watch()` observes from inside the distro (a `find -newer` poll loop, see
91
- `lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
92
- and they arrive on the poll cadence, not instantly. A creation is seen even when
93
- the new file's own mtime is old: creating it bumps the containing directory's
94
- mtime, and the scan includes the watched directory itself (deletions are seen the
95
- same way). What escapes the stamp is a change that leaves every scanned mtime
96
- older than it — an in-place `cp -p` onto an existing file (new content, a
97
- copied-back mtime), or a `tar -x` whose archive restores the containing
98
- directory's own mtime. Watching a 9p share from the Windows side remains
99
- unsupported — the loop runs where the kernel can actually report change.
96
+ - `watch()` observes from inside the distro, so callbacks are coarse
97
+ invalidation, not per-file notifications. With inotify they arrive as events
98
+ (coalesced to 0.5s); with the polling fallback they arrive on the poll
99
+ cadence, not instantly. In the polling path a creation is seen even when the
100
+ new file's own mtime is old: creating it bumps the containing directory's
101
+ mtime, and the scan includes the watched directory itself (deletions are seen
102
+ the same way). What escapes the polling stamp is a change that leaves every
103
+ scanned mtime older than it — an in-place `cp -p` onto an existing file (new
104
+ content, a copied-back mtime), or a `tar -x` whose archive restores the
105
+ containing directory's own mtime. That blind spot does not apply to the
106
+ inotify path. A FILE target is watched by its `mtime:size` fingerprint: a
107
+ rename-over or an in-place write fires, a removal reports the target as gone,
108
+ and a change that preserves both mtime and size escapes it. Watching a 9p
109
+ share from the Windows side remains unsupported — the loop runs where the
110
+ kernel can actually report change.
100
111
  - `editText` reads the whole file into memory before writing it back.
101
112
 
102
113
  ## The sandbox
103
114
 
115
+ - **The one-shot fallback has only the host deadline.** With `agent: false`, or
116
+ when the resident is out of service, a command runs through a fresh
117
+ `wsl.exe`. The host kills `wsl.exe` at the timeout, but WSL does not relay
118
+ that kill into the distro, so the Linux-side process tree can outlive the
119
+ call. The session runtime path — the shipped default — is cleaned by its
120
+ scope. A systemd-scoped one-shot (with explicit environment pass-through) is
121
+ not shipped; prefer `agent: true` and the session runtime for long commands.
122
+ - **The native backend is the default; aarch64 is build-tested only.** The
123
+ packaged x86_64 musl binary runs the 18-check suite in CI. The same job
124
+ builds the aarch64 binary, but the runner cannot execute it, so its
125
+ functional validation on real aarch64 hardware (or qemu) is still pending.
126
+ `agentBackend: auto` still selects it on an aarch64 distro; `native` refuses
127
+ if preparation fails, and `shell` forces the shell agent.
128
+ - **The session runtime needs systemd and a lingering user manager.** The
129
+ shipped preset sets `sessionSandbox: true`; a distro without systemd as PID 1,
130
+ a reachable `systemd --user`, `systemd-run`, or `Linger` is refused on the
131
+ first command with the remedy. Set `sessionSandbox: false` on the preset's
132
+ `wsl-shell` to keep the per-command shape. A **hard crash** of the app can
133
+ leave a stale `dsh-wsl-*.scope` and its background processes behind; a
134
+ graceful session end stops the scope (validated). The session preflight
135
+ sweeps stale scopes when the next WSL session opens: it stops a
136
+ `dsh-wsl-*.scope` only when its cgroup has no agent process (`wsl-agent.sh`
137
+ or the native `dsh-wsl-agent`), so another live session's runtime is never
138
+ touched.
104
139
  - **The sandbox does not govern WSL interop.** A confined command can still run a
105
140
  Windows program, which escapes the Linux-side boundary. The plugin reports this as
106
141
  `enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
package/docs/PARITY.md CHANGED
@@ -7,7 +7,9 @@ deviation, if any, still stands.
7
7
 
8
8
  Verified against: `dsh-plugin-wsl-env@0.8.0` / peer `0.2.0-rc.2` / agent
9
9
  protocol v4, 2026-10-06. Update this line when the peer pin or the agent
10
- protocol moves.
10
+ protocol moves. The peer pin moved to `0.2.1-rc.2` on 2026-10-17 with every
11
+ seam package's shipped artifact byte-identical to the ones these rows were
12
+ measured against; the real-machine probes were not re-run in that move.
11
13
 
12
14
  ## What parity means here
13
15
 
@@ -7,12 +7,18 @@ same refusal dialect on both substrates. The **read path** holds end to end;
7
7
  the **edit path** carries two declared divergences, listed at the end. This
8
8
  file records the verification against the real peers.
9
9
 
10
- - Peers: `@deepseek-ai/dsh-fs-local@0.2.0-rc.2` (the exact version
11
- `package.json` pins), plus `@deepseek-ai/dsh-fs@0.2.0-rc.2` for the
10
+ - Peers: `@deepseek-ai/dsh-fs-local@0.2.1-rc.2` (the exact version
11
+ `package.json` pins), plus `@deepseek-ai/dsh-fs@0.2.1-rc.2` for the
12
12
  `FsVersion` brand the local package imports. Divergence 2 is measured
13
- against a third peer, `@deepseek-ai/dsh-bash-local@0.2.0-rc.2` — the shell
13
+ against a third peer, `@deepseek-ai/dsh-bash-local@0.2.1-rc.2` — the shell
14
14
  executor carries the collect-budget/spill machinery; `spill` and
15
15
  `maxSpillBytes` do not appear in `dsh-fs-local` at all.
16
+ - The `0.2.1-rc.2` move leaves every line reference below intact: all three
17
+ peers' shipped `lib/index.js` are **byte-identical** to the `0.2.0-rc.2`
18
+ ones (`cmp` clean; `dsh-fs-local`'s is still 909 lines), so only the
19
+ tarballs' digests changed. `dsh-fs@0.2.1-rc.2` did drop its unpublished
20
+ `./invariant` export subpath; this plugin imports neither it nor
21
+ `dsh-invariants`.
16
22
  - What "not imported" means: the peer's **fsio module** is not reachable from
17
23
  the package's exports, so `lib/fsio-text.js` reimplements it and imports
18
24
  nothing from it (the export is proposed in `docs/UPSTREAM-FSIO-EXPORT.md`).
@@ -27,23 +33,24 @@ file records the verification against the real peers.
27
33
 
28
34
  ```sh
29
35
  mkdir -p /tmp/peer-audit && cd /tmp/peer-audit
30
- npm pack @deepseek-ai/dsh-fs-local@0.2.0-rc.2 @deepseek-ai/dsh-fs@0.2.0-rc.2 --pack-destination .
31
- openssl dgst -sha512 -binary deepseek-ai-dsh-fs-local-0.2.0-rc.2.tgz | base64 -w0
32
- tar xzf deepseek-ai-dsh-fs-local-0.2.0-rc.2.tgz # -> package/lib/index.js
36
+ npm pack @deepseek-ai/dsh-fs-local@0.2.1-rc.2 @deepseek-ai/dsh-fs@0.2.1-rc.2 --pack-destination .
37
+ openssl dgst -sha512 -binary deepseek-ai-dsh-fs-local-0.2.1-rc.2.tgz | base64 -w0
38
+ tar xzf deepseek-ai-dsh-fs-local-0.2.1-rc.2.tgz # -> package/lib/index.js
33
39
  wc -l package/lib/index.js # expect 909
34
40
  ```
35
41
 
36
42
  The tarballs this audit compared, by content — the same values
37
- `npm view <pkg>@0.2.0-rc.2 dist.integrity` reports:
43
+ `npm view <pkg>@0.2.1-rc.2 dist.integrity` reports:
38
44
 
39
45
  ```
40
- @deepseek-ai/dsh-fs-local@0.2.0-rc.2 sha512-lDzh5VlQsXf3nI+E0+T0weYdH1VAoo+htj6Kwc62P7/Lx+xjnYSLQYTeGeaJR1d8H2iQv2FvlwaDKv60AorKdA==
41
- @deepseek-ai/dsh-fs@0.2.0-rc.2 sha512-PuVcI7drTwa19Key54DROoqnDFTTFkl8TPlyrTnS3BSnykm+jKbB13ES/PQEJoRunNdQkNZa12jtbUI4BP/EHw==
46
+ @deepseek-ai/dsh-fs-local@0.2.1-rc.2 sha512-IZPJ12/EpV6fu3CCWtW4w4GthXAKLFjplm4Feoep2HAum7J1aGzHZwiw7ipsPCZiA5/TyK3lDUUAGZ/pcXgRcw==
47
+ @deepseek-ai/dsh-fs@0.2.1-rc.2 sha512-Z6d2ZKsAiJc0FaiES8wC66vuUQMGohl9tCc2p3bAsPFYkJ0+L2zTViQ9Y5BArtRe9Br8qhNjNgLlN0jb/uwv1g==
42
48
  ```
43
49
 
44
50
  Each row of the table below names the peer's `lib/index.js` line and the
45
51
  literal both sides must produce. Verified 2026-10-03; the read/edit split
46
- re-verified 2026-10-06.
52
+ re-verified 2026-10-06; the whole table re-checked against `0.2.1-rc.2`,
53
+ 2026-10-17.
47
54
 
48
55
  ## Verdict
49
56
 
@@ -86,7 +93,7 @@ declared extensions, listed at the end.
86
93
  2. **Per-stream truncation flags.** The EXEC frame carries an output budget;
87
94
  the RES line reports which streams the agent cut, surfaced as honest
88
95
  `truncated` flags. The collect-budget/spill machinery of the shell peer,
89
- `@deepseek-ai/dsh-bash-local@0.2.0-rc.2` (`lib/index.js:30`, `:110`), is the
96
+ `@deepseek-ai/dsh-bash-local@0.2.1-rc.2` (`lib/index.js:30`, `:110`), is the
90
97
  spill-file path we deliberately do not run.
91
98
  3. **The transport around the ops** (ACK, KILL, the confined resident) is
92
99
  protocol this package owns; the peer has no agent.