dsh-plugin-wsl-env 0.11.0 → 0.13.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 (45) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTRIBUTING.md +29 -7
  3. package/README.md +124 -138
  4. package/SECURITY.md +11 -1
  5. package/SUPPORT.md +29 -2
  6. package/cordis.patch.yml +17 -19
  7. package/docs/ARCHITECTURE.md +30 -29
  8. package/docs/CONFIGURATION.md +81 -21
  9. package/docs/LIMITATIONS.md +71 -6
  10. package/docs/MIGRATION.md +98 -0
  11. package/docs/PARITY.md +1 -1
  12. package/docs/PERFORMANCE.md +60 -0
  13. package/docs/QUICKSTART.md +125 -0
  14. package/docs/README.zh.md +117 -137
  15. package/docs/ROADMAP-1.0.md +780 -0
  16. package/docs/ROADMAP.md +4 -0
  17. package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +80 -0
  18. package/docs/UPSTREAM-LSP-SEAM.md +90 -0
  19. package/docs/UPSTREAM-SESSION-NOTICE.md +95 -0
  20. package/lib/agent-errors.d.ts +19 -0
  21. package/lib/agent-errors.js +22 -0
  22. package/lib/agent-exec.js +2 -2
  23. package/lib/agent.d.ts +68 -5
  24. package/lib/agent.js +129 -22
  25. package/lib/distro-route.d.ts +75 -0
  26. package/lib/distro-route.js +86 -0
  27. package/lib/fs-routing.d.ts +4 -1
  28. package/lib/index.d.ts +166 -37
  29. package/lib/index.js +353 -75
  30. package/lib/paths.d.ts +15 -0
  31. package/lib/paths.js +28 -0
  32. package/lib/preflight.d.ts +46 -3
  33. package/lib/preflight.js +83 -14
  34. package/lib/shell-env.d.ts +9 -3
  35. package/lib/shell-env.js +121 -43
  36. package/lib/subprocess.d.ts +12 -3
  37. package/lib/subprocess.js +21 -3
  38. package/package.json +23 -15
  39. package/scripts/diagnose.sh +9 -88
  40. package/scripts/doctor.mjs +548 -0
  41. package/scripts/profile-preset.mjs +254 -0
  42. package/lib/workspace-files-route.d.ts +0 -136
  43. package/lib/workspace-files-route.js +0 -225
  44. package/lib/workspace-files-wsl.d.ts +0 -257
  45. package/lib/workspace-files-wsl.js +0 -418
@@ -26,19 +26,25 @@ root-plane services, and adds three smaller integrations.
26
26
  extends the shipped `LocalFileSystem`. Only the parts that need a distro are
27
27
  overridden.
28
28
 
29
- ## The two root-plane takeovers
29
+ ## The root-plane filesystem takeover
30
30
 
31
- Two of the shipped services are session-less — the GUI file tree and the root
32
- `ctx.fs` — so they cannot be served from an agent preset. The layer replaces
33
- their shipped rows outright, with subclasses that route by coordinate:
31
+ One of the shipped services is session-less — the root `ctx.fs` — so it cannot
32
+ be served from an agent preset. The layer replaces its shipped row outright with
33
+ a subclass that routes by coordinate:
34
34
 
35
35
  | Service | Class | File | What it does |
36
36
  |---|---|---|---|
37
- | `ctx.workspaceFiles` | `WorkspaceFilesWsl` | [`lib/workspace-files-wsl.js`](../lib/workspace-files-wsl.js) | The GUI file tree and previews for a `\\wsl.localhost\<distro>` workspace, served from inside the distro over the resident agent instead of Windows-side 9P walks. Extends the shipped service: a distro UNC routes distro-side, a drive path calls `super`, and the watch stream delegates unchanged — so replacing the row is safe for Windows-folder sessions too. Replaces the shipped `workspace-files` row. |
38
37
  | `ctx.fs` (root plane) | `WslRoutingFileSystem` | [`lib/fs-routing.js`](../lib/fs-routing.js) | The root filesystem, routed by coordinate: a distro UNC identity is served by the resident agent (real watch events, ext4 reads), a drive path stays host-native. Replaces the shipped `fs-sandbox` row, whose write fence has no root-plane consumers — the model's writes ride the preset filesystems. [`root-fs-routing.md`](root-fs-routing.md) is the design. |
39
38
 
40
- Both routes are composition-level for the mirror-image reason the terminal is
41
- (see below): no session identity reaches them. Each takeover is a top-level
39
+ The GUI file tree and previews keep the shipped `workspace-files` row. Its
40
+ client half is the browser `file` resource provider the Sidebar previews need,
41
+ so replacing the row with a server-only subclass would drop it; the shipped
42
+ service reads WSL paths through the routed root filesystem instead. The cost is
43
+ extra `ctx.fs` round trips per listing, `stat` or page read (see
44
+ [LIMITATIONS](LIMITATIONS.md)).
45
+
46
+ The takeover is composition-level for the mirror-image reason the terminal is
47
+ (see below): no session identity reaches the root filesystem. It is a top-level
42
48
  `disabled:` entry targeting the shipped row plus a bare `insert:` list holding
43
49
  the replacement — because a `disabled` written *inside* an insert list is not a
44
50
  patch but a duplicate-id data row, and a nested `insert:` is never interpreted at
@@ -60,7 +66,7 @@ functions.
60
66
  |---|---|---|---|
61
67
  | `dsh-plugin-wsl-env/picker` | `WslDirectoryPicker` | [`lib/picker.js`](../lib/picker.js) | Adds the installed distros to the folder picker, next to the Windows home directory, and lists distro levels from inside the distro (the resident's `ls`, one-shot `wsl.exe` when it is out). |
62
68
  | `dsh-plugin-wsl-env/auto-preset` | `auto-preset` | [`lib/auto-preset.js`](../lib/auto-preset.js) | Binds the `wsl` agent preset when a new session's workspace is inside a distro. |
63
- | `dsh-plugin-wsl-env/shell-env` | `wsl-shell-env` | [`lib/shell-env.js`](../lib/shell-env.js) | Contributes `DSH_WSL_DISTRO`, `DSH_WSL_SHELL`, `DSH_WSL_HOME` and `DSH_WSL_PORTS` to the managed `DSH_*` namespace. The port list is a snapshot, refreshed every `portsRefreshMs` (10000 by default), so a server started moments ago may not be listed yet. |
69
+ | `dsh-plugin-wsl-env/shell-env` | `wsl-shell-env` | [`lib/shell-env.js`](../lib/shell-env.js) | Contributes `DSH_WSL_DISTRO`, `DSH_WSL_SHELL`, `DSH_WSL_HOME` and `DSH_WSL_PORTS` to the managed `DSH_*` namespace, choosing the session workspace's distro (default/pinned facts are probed at mount; other distros are warmed when their session appears). The port list is a snapshot per active distro, refreshed every `portsRefreshMs` (10000 by default), so a server started moments ago may not be listed yet. |
64
70
 
65
71
  Three modules support them and import no DSH package.
66
72
  [`lib/paths.js`](../lib/paths.js) translates paths.
@@ -79,9 +85,9 @@ The environment is per session, not per process:
79
85
  |---|---|---|
80
86
  | `wsl-shell`, `wsl-fs` | inside `preset-wsl`, an agent preset with isolate realms | so one process can serve a Windows workspace and a distro workspace at the same time |
81
87
  | `subprocess-wsl` | at the app level, called the composition; the shipped `subprocess` row is disabled | because no session identity reaches the terminal controller, see below |
82
- | `workspace-files-wsl` | at the app level; the shipped `workspace-files` row is disabled | the GUI file tree is session-less, so it cannot be served from a preset |
88
+ | `workspace-files` | shipped, not replaced | its client half is the browser `file` resource provider the Sidebar previews need; it reads WSL paths through the routed root filesystem |
83
89
  | `fs-routing` | at the app level; the shipped `fs-sandbox` row is disabled | the root `ctx.fs` is session-less, and its write fence has no root-plane consumers |
84
- | `directory-picker-wsl`, `wsl-shell-env`, `auto-preset` | at the app level | they answer questions that are not scoped to a session |
90
+ | `directory-picker-wsl`, `wsl-shell-env`, `auto-preset` | at the app level | they answer questions that are not scoped to a single preset; `shell-env` resolves its facts per session workspace |
85
91
 
86
92
  The preset mount requires the isolate realms. Without them the registry rejects the
87
93
  providers with `Preset services require isolate realms: shell, fs`.
@@ -282,16 +288,17 @@ failures are named as well. Every `FsError` code the plugin itself raises appear
282
288
  below — a model sees the code, and a reader asking what refused them finds it here.
283
289
  Codes the shipped host stack raises behind a drive path pass through
284
290
  `lib/fs-routing.js` unchanged and are the peer's, not this table's; the GUI file
285
- tree's own wire refusals are a separate dialect, listed after the table.
291
+ service is the shipped `workspace-files` row, so its wire refusals are the peer's
292
+ own dialect, described after the table.
286
293
 
287
294
  | Code | Raised by | Meaning | What clears it |
288
295
  |---|---|---|---|
289
296
  | `SANDBOX_UNAVAILABLE` | `WslSandbox.confine` | the requested mode cannot be enforced: no usable `bwrap` in the distro | install `bubblewrap` there (a failed probe is re-run, so no restart is needed), or set `sandbox: false` |
290
297
  | `FS_SANDBOX_DENIED` | `WslFileSystem.checkedTarget`, and `lib/fsio-agent.js` (a write the confined resident's mount table refused, classified from the kernel's `read-only file system`) | the file-effect policy refused the write: `read-only`, or a target outside the writable roots — or the kernel refused it at the resident's profile | a wider permission for that one call, or a session workspace that contains the target |
291
- | `FS_OUTSIDE_DISTRO` | `WslFileSystem.worldPath` | `restrictToDistro` is on and the path names **another distro**'s share — a configuration fence, not a sandbox decision | open a session in that distro, or set `restrictToDistro: false`. A wider permission does **not** lift it |
298
+ | `FS_OUTSIDE_DISTRO` | `WslFileSystem.worldPath` | `distro` is fixed (`default` or a name), `restrictToDistro` is on, and the path names **another distro**'s share — a configuration fence, not a sandbox decision. With `distro: ''`/`auto` a UNC target is served by its own distro, so the fence has nothing foreign to refuse | set `distro` to `''`/`auto` (the default), or open a session in that distro. A wider permission does **not** lift it |
292
299
  | `FS_NOT_OBSERVED` | `lib/fs-substrate.js` (`writeText`; the agent's `exists` conclusion, mapped in `lib/fsio-agent.js`) | a guarded create (`createIfAbsent`) targeted a file that exists and had not been read first | read the file, then overwrite with the version guard or edit it |
293
300
  | `FS_STALE_VERSION` | `lib/fs-substrate.js` (`writeText`, `editText`; the agent's `stale` conclusion, mapped in `lib/fsio-agent.js`) | the version the caller holds no longer matches: the file changed, or is gone | read it again and retry with the fresh version |
294
- | `FS_IO_ERROR` | the filesystem stack: `lib/fs-decisions.js` (the substrate is unavailable), `lib/fsio-agent.js` (a `perm`/`loop`/`io` reason or a malformed record), `lib/index.js` (a path that cannot be mapped, or a `SandboxUnavailableError`), and `WslFileSystem.watch` | the catch-all I/O failure — most often the resident agent is out or the distro returned an I/O error, the "agent outage fails closed" case above. A watch target outside the pinned distro is the narrow one | get the resident back (check `wsl.exe -l -v`, then restart the session); for a watch, aim it at a distro path — the watch itself runs where the files live (`lib/watcher.js`) |
301
+ | `FS_IO_ERROR` | the filesystem stack: `lib/fs-decisions.js` (the substrate is unavailable), `lib/fsio-agent.js` (a `perm`/`loop`/`io` reason or a malformed record), `lib/index.js` (a path that cannot be mapped, or a `SandboxUnavailableError`), and `WslFileSystem.watch` | the catch-all I/O failure — most often the resident agent is out or the distro returned an I/O error, the "agent outage fails closed" case above. A watch target outside the selected distro is the narrow one | get the resident back (check `wsl.exe -l -v`, then restart the session); for a watch, aim it at a distro path — the watch itself runs where the files live (`lib/watcher.js`) |
295
302
  | `FS_NOT_FOUND` | `lib/fsio-agent.js` and `lib/fs-substrate.js` (the agent's `notfound`/`notdir` conclusion, an empty path, or a missing edit target), and the host filesystem stack behind a drive path | the path does not exist — the ordinary answer | — |
296
303
  | `FS_NOT_REGULAR_FILE` | `lib/fsio-agent.js`, `lib/fs-substrate.js` | the target is not a regular file where one is required: a directory, symlink or device on a read, or a non-file target on a write | point the call at a regular file |
297
304
  | `FS_NOT_DIRECTORY` | `lib/fsio-agent.js` (`listChildren`) | the target is not a directory, so there are no children to list | list a directory |
@@ -301,14 +308,11 @@ tree's own wire refusals are a separate dialect, listed after the table.
301
308
  | `FS_EDIT_NOT_FOUND` | `lib/fsio-text.js` (`applyLiteralEdit`) | `old_string` was empty, or matched nothing in the file | give an `old_string` the file actually contains |
302
309
  | `FS_AMBIGUOUS_EDIT` | `lib/fsio-text.js` (`applyLiteralEdit`) | `old_string` matched more than once and `replace_all` was not set | make `old_string` unique, or set `replace_all` |
303
310
 
304
- The GUI file tree speaks a second dialect. `WorkspaceFilesWsl` refuses through the
305
- typert wire's `RemoteError`s, not `FsError`s, so its codes are not in the table
306
- above: `workspace-file/not-found`, `workspace-file/outside-workspace`,
307
- `workspace-file/not-directory`, `workspace-file/not-regular-file`,
308
- `workspace-file/not-text`, and `workspace-file/too-large` (one code for both the
309
- full-file cap and the byte-window cap), plus `gateway/bad-request` for a malformed
310
- page request (a non-integer offset, or a limit above `maxLines`). A drive-path
311
- workspace calls the shipped service and gets the peer's own answers instead.
311
+ The GUI file service keeps the shipped `workspace-files` row, so its wire
312
+ refusals are the peer's `workspace-file/*` dialect (plus `gateway/bad-request`
313
+ for a malformed page request), not this plugin's `FsError` codes. The plugin
314
+ supplies the routed `ctx.fs`; an agent outage reaches the service as
315
+ `FS_IO_ERROR` and the peer maps it.
312
316
 
313
317
  Two failures come from `wsl.exe` itself rather than from a code of ours, and both used
314
318
  to be invisible:
@@ -347,7 +351,7 @@ step with the checkout, and `pnpm run sync:windows` runs that script.
347
351
  | Type check | `tsc --noEmit` over `test/**` and `types/*.d.ts` (`src/` is checked by the build itself) | no |
348
352
  | Unit tests | `test/*.test.mjs` | no |
349
353
  | Sandbox probe | [`test/probe/sandbox.sh`](../test/probe/sandbox.sh) | no; it applies the profile arguments directly |
350
- | Harness probes | [`test/probe/run.sh`](../test/probe/run.sh), `terminal.sh`, `sandbox-shell.sh`, `picker.sh`, `missing-wsl.sh`, `sandbox-off.sh` | yes: Windows, WSL2, a mounted profile, and a linked checkout |
354
+ | Harness probes | [`test/probe/run.sh`](../test/probe/run.sh), `terminal.sh`, `sandbox-shell.sh`, `picker.sh`, `fs-routing.sh`, `missing-wsl.sh`, `sandbox-off.sh` | yes: Windows, WSL2, a mounted profile, and a linked checkout |
351
355
  | Distro probes | [`test/probe/substrate.sh`](../test/probe/substrate.sh), `watch.sh`, `agent.sh`, `exec.sh` | Windows and WSL2, but no harness boot and no profile: they run from a distro terminal with Node available, reaching `wsl.exe` through interop |
352
356
  | Host probe | [`test/probe/mode.sh`](../test/probe/mode.sh) | Windows Node, no harness |
353
357
  | Probe plugins | the `*-probe.mjs` and `*-probe.yml` files in `test/probe/` | loaded by the scripts above |
@@ -361,9 +365,8 @@ waits for the app to exit and then runs the whole real-machine suite once —
361
365
  The unit tests come in two kinds: most import only Node builtins, while
362
366
  [`test/composition.test.mjs`](../test/composition.test.mjs),
363
367
  [`test/fs.boot.test.mjs`](../test/fs.boot.test.mjs),
364
- [`test/fsio-agent.test.mjs`](../test/fsio-agent.test.mjs),
365
- [`test/provider.test.mjs`](../test/provider.test.mjs) and
366
- [`test/workspace-files-wsl.test.mjs`](../test/workspace-files-wsl.test.mjs) import
368
+ [`test/fsio-agent.test.mjs`](../test/fsio-agent.test.mjs) and
369
+ [`test/provider.test.mjs`](../test/provider.test.mjs) import
367
370
  pinned `@deepseek-ai/*` development dependencies (the composition and boot tests run
368
371
  the loader's real patch algorithm). One `pnpm install` reproduces that tree before
369
372
  the first run; the runtime package itself still ships zero dependencies. CI runs
@@ -413,6 +416,8 @@ src/auto-preset.ts per-session environment selection
413
416
  src/bwrap.ts the bwrap command line every confinement site
414
417
  assembles (no imports, so a bare checkout can
415
418
  assert it)
419
+ src/distro-route.ts the pure distro-mode selector (pinned / workspace /
420
+ default) shared by shell, fs, preflight and env
416
421
  src/file-reference-wsl.ts the distro-side `@`-completion traversal — built,
417
422
  tested, not yet wired (see above)
418
423
  src/fs.ts the ctx.fs subpath entry point
@@ -444,10 +449,6 @@ src/terminal-activity.ts the distro-side activity probe for idle terminal
444
449
  reclamation
445
450
  src/terminal-route.ts which execution world a terminal launch belongs to (pure)
446
451
  src/watcher.ts in-distro file watching for WslFileSystem.watch
447
- src/workspace-files-route.ts pure builders and parsers for the routed
448
- workspace-files service
449
- src/workspace-files-wsl.ts WorkspaceFilesWsl (ctx.workspaceFiles), the GUI
450
- file tree and previews
451
452
  src/wsl.ts wsl.exe interop primitives (no DSH imports)
452
453
  ```
453
454
 
@@ -7,7 +7,7 @@ profile layer: `$DSH_HOME/profiles/<name>/cordis.patch.yml`. Start from
7
7
  reference for every value the plugin ships.
8
8
 
9
9
  Which rows a patch can reach depends on where they sit. `fs-routing`,
10
- `workspace-files-wsl`, `directory-picker-wsl`, `subprocess-wsl`, `wsl-shell-env`
10
+ `directory-picker-wsl`, `subprocess-wsl`, `wsl-shell-env`
11
11
  and `preset-wsl` are top-level rows, so `- id: <row>` in your layer patches them.
12
12
  `wsl-shell` and `wsl-fs` are not top-level: they are plugins of the `wsl` agent
13
13
  preset, nested inside the `preset-wsl` row's `config.plugins`. A patch that
@@ -22,44 +22,45 @@ has its own table at the end of this page.
22
22
 
23
23
  | Row | Key | Default | Meaning |
24
24
  |---|---|---|---|
25
- | `wsl-shell` | `distro` | `''` *(shipped)* | distro name; empty means WSL's default distro |
25
+ | `wsl-shell` | `distro` | `''` *(shipped)* | distro mode: empty/`auto` follows the request workdir, `default` forces WSL's default, and a name pins every command |
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 |
29
+ | | `sessionSandbox` | `true` *(shipped)* | `true`, `false`, or `auto`. With `true`, run this session's commands in one persistent systemd user scope (ADR 0001): a background dev server survives the command that started it, `/tmp` is the distro's real `/tmp` under `workspace-write`, and `ps` sees the session. The executor holds the runtime for the session and stops the scope from the preset's disposer, which reaps the cgroup. Requires `agent: true`, systemd as PID 1, a reachable user manager and `Linger` (`loginctl enable-linger <user>`); the preflight refuses with the remedy when systemd, the user manager, `systemd-run` or `Linger` is missing (a success is cached; a failure is re-probed, so following the remedy needs no app restart). `auto` uses the scope when those prerequisites answer, and otherwise falls back to the per-command `bwrap` profile — still confined, never unconfined — reporting `sessionScope: "per-command"` in the result (`"session"` when the scope ran); a failed `auto` verdict is re-probed at most once a minute. `false` is the per-command shape. On in the shipped preset |
30
30
  | | `agentBackend` | `auto` | the session runtime's in-distro agent. `auto` uses the native agent when a verified source is available — the packaged per-arch static-musl binary at `agent/native/linux-<arch>/dsh-wsl-agent` (x86_64 and aarch64; runs on glibc and musl distros), or the `DSH_WSL_AGENT_NATIVE` development override — and otherwise the shipped shell agent; `native` fails the first command when it cannot prepare instead of silently falling back; `shell` forces the shell agent. Confined writes share the runtime, so they follow the same selection. The native backend is the default (static musl x86_64/aarch64); the shell agent is the fallback, and `native` refuses instead of falling back. This key covers the session runtime (commands and confined writes) and, since A7, the shared read agent (unconfined reads), which also follows this selection with the shell fallback |
31
31
  | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox. Needs a usable `bwrap` inside the distro and fails closed without one (`SANDBOX_UNAVAILABLE`, never an unconfined run) — see [Turning the sandbox off](#turning-the-sandbox-off) |
32
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` |
33
33
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
34
34
  | | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
35
- | `wsl-fs` | `distro` | `''` *(shipped)* | as above |
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
+ | `wsl-fs` | `distro` | `''` *(shipped)* | distro mode: empty/`auto` follows each target path, `default` forces WSL's default, and a name pins the filesystem |
36
+ | | `restrictToDistro` | `true` *(shipped)* | under a fixed `distro` (`default` or a name), refuse a path in **another distro**'s share (`FS_OUTSIDE_DISTRO`). Under `auto`, a UNC target is served by its own distro, so the fence has nothing foreign to refuse. `/mnt/c` is a directory *inside* the selected distro and is not affected |
37
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 |
38
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 |
39
+ | | `sessionSandbox` | `true` *(shipped)* | `true`, `false`, or `auto`. With `true`, 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. `auto` uses the runtime when the prerequisites answer and otherwise falls back to the per-policy confined resident (still kernel-confined). Set `false` for the per-policy confined resident. The app-level `fs-routing` backend is not session-owned and keeps this off |
40
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 |
41
41
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
42
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 |
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 |
43
+ | `fs-routing` | `distro` | `''` *(shipped)* | distro mode for the root-plane filesystem: empty/`auto` follows each target's UNC distro, `default` forces WSL's default, and a name pins. **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 |
44
44
  | | `wslPath` | `wsl.exe` *(shipped)* | path to `wsl.exe`; used here only to resolve the default distro |
45
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 |
46
46
  | | `maskWindowsDrive` | `false` *(shipped)* | as on `wsl-shell`, for routed mutations |
47
- | | `restrictToDistro` | `true` *(shipped)* | refuse a path in another distro's share (`FS_OUTSIDE_DISTRO`) |
47
+ | | `restrictToDistro` | `true` *(shipped)* | under a fixed `distro` (`default` or a name), refuse a path in another distro's share (`FS_OUTSIDE_DISTRO`); under `auto`, each UNC target is served by its own distro |
48
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 |
49
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 |
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 |
51
- | | `maxFileBytes` | `33554432` *(shipped)* | full-file byte cap for the byte reader |
52
- | | `maxLines` | `5000` *(shipped)* | line cap on one text read |
53
- | | `maxEntries` | `2000` *(shipped)* | directory entries listed per level before the listing is flagged truncated |
54
50
  | `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
55
51
  | | `includeHostHome` | `true` *(shipped)* | also list the Windows home directory |
56
52
  | | `maxEntries` | `1000` *(shipped)* | maximum entries listed per directory |
57
- | `subprocess-wsl` | `distro` | `''` *(shipped)* | which distro the GUI terminal opens in |
53
+ | `subprocess-wsl` | `distro` | `''` *(shipped)* | distro mode for the GUI terminal: empty/`auto` follows the launch directory, `default` forces WSL's default, and a name pins |
58
54
  | | `shell` | `''` | pin a shell; empty lets `wsl.exe` decide |
59
55
  | | `loginShell` | `true` | whether a pinned shell uses login semantics |
60
56
  | | `hostSessions` | `true` | give a session opened on a Windows folder a host `powershell.exe` in its own directory instead of the distro shell at `/mnt/<drive>/…`; `false` restores the composition-owned distro terminal for every session |
61
57
  | | `terminalIdleReclaim` | `true` | let the controller reclaim an idle distro terminal: the launch is marked with a per-terminal `DSH_TERMINAL_ID`, and the handle's `inspectActivity` counts the marked processes through the resident agent — one (the shell at its prompt) is idle, more than one (a running command or a nested shell) is busy, `unknown` (agent out) pauses reclamation. `false` restores close-by-hand |
62
58
 
59
+ The GUI file tree and previews use the shipped `workspace-files` row, not a plugin
60
+ row. Its caps are the peer defaults (`maxBytes` 2 MiB, `maxFileBytes` 32 MiB,
61
+ `maxLines` 5000, `maxEntries` 2000); override them with `- id: workspace-files`
62
+ in your own layer.
63
+
63
64
  ## Overriding the rows inside `preset-wsl`
64
65
 
65
66
  `wsl-shell` and `wsl-fs` are mounted as plugins of the `wsl` agent preset, inside
@@ -82,6 +83,21 @@ changes nothing and does not fail: the log carries
82
83
  `patch: entry "wsl-shell" not found` and `patch: entry "wsl-fs" not found`, and the
83
84
  composed config keeps the shipped values.
84
85
 
86
+ The helper does the extraction and the common edits for you:
87
+
88
+ ```bash
89
+ pnpm run preset -- --distro Ubuntu --session-sandbox false \
90
+ --out "$DSH_HOME/profiles/<name>/cordis.patch.yml"
91
+ ```
92
+
93
+ `--distro`, `--session-sandbox`, `--sandbox`, `--mask-windows-drive` and
94
+ `--agent-backend` are the supported edits; without `--out` the helper prints the
95
+ complete block instead of writing it. `pnpm run preset -- --check <file>` reads an
96
+ existing layer and **fails with the exact block to paste** when it finds a
97
+ top-level `- id: wsl-shell`/`wsl-fs` entry — the silent no-op is caught before
98
+ boot. The generated block is byte-for-byte the shipped row plus your edits, so a
99
+ plugin upgrade only needs a re-run.
100
+
85
101
  The route that works is to override the `preset-wsl` row as a whole. A row's
86
102
  `config` is replaced whole, never deep-merged, so the replacement has to carry
87
103
  `id`, `order` and the entire `plugins` list — every plugin, the `wsl-env` group
@@ -163,9 +179,43 @@ is out of service and the call rides the one-shot `wsl.exe` path.
163
179
 
164
180
  ## Which distro is used
165
181
 
166
- `distro: ''` means the distro `wsl.exe` treats as default, so an installation with a
167
- single distro needs no configuration. Name one when you have several, or when the
168
- default is not the one you want sessions in.
182
+ `distro: ''` (or `distro: 'auto'`) follows the workspace: opening
183
+ `\\wsl.localhost\Debian\...` runs there even when Ubuntu is WSL's default.
184
+ `distro: 'default'` forces WSL's default instead, and a fixed distro (`default` or
185
+ a name) pins every session to that distro — a foreign UNC then refuses with
186
+ `FS_OUTSIDE_DISTRO` while `restrictToDistro` holds. A single-distro installation
187
+ needs no configuration.
188
+
189
+ ## The strict profile
190
+
191
+ `maskWindowsDrive: true` with `sandbox: true` is the strict profile: every
192
+ confined profile shadows `/mnt` with an empty tmpfs, so a confined command can
193
+ neither read the Windows drive nor launch a Windows executable from it. Write it
194
+ into a profile with the preset helper (the rows are nested inside `preset-wsl`):
195
+
196
+ ```bash
197
+ pnpm run preset -- --sandbox true --mask-windows-drive true \
198
+ --out "$DSH_HOME/profiles/<name>/cordis.patch.yml"
199
+ ```
200
+
201
+ The trade-offs are real: confined commands lose `/mnt/c` entirely — no reading
202
+ Windows files, no running Windows tools, no dev server that expects a Windows
203
+ path. It is also a narrowing, not a closure: a command can still write a Windows
204
+ executable into the workspace and run it there, because WSL's `binfmt_misc`
205
+ dispatches on content, not location. Enforcement therefore stays `partial`, and
206
+ the result reports `windowsDrive: "masked"`. The only complete closure is
207
+ distro-level: `[interop] enabled=false` in `/etc/wsl.conf`, then
208
+ `wsl.exe --shutdown`, which removes interop from the distro entirely.
209
+
210
+ | Interop state | How | `/mnt` | Windows exe from `/mnt` | Workspace-written `.exe` | Evidence |
211
+ |---|---|---|---|---|---|
212
+ | visible (default) | — | readable | runs, and escapes the Linux sandbox | runs | `pnpm run probe:sandbox` — INFO "interop escape is open" |
213
+ | masked (strict) | `maskWindowsDrive: true` | empty tmpfs | unreachable | still runs (binfmt) | `pnpm run probe:sandbox` — the strict-profile checks |
214
+ | disabled | `[interop] enabled=false`, `wsl --shutdown` | no Windows mounts | n/a | fails (no binfmt) | documented; not probed automatically (needs a distro-level change) |
215
+
216
+ The masked and visible legs are asserted on the real machine by
217
+ `test/probe/sandbox.sh`; the disabled leg needs a distro configuration change and
218
+ a WSL shutdown, so it stays a manual check recorded here.
169
219
 
170
220
  ## Turning the sandbox off
171
221
 
@@ -194,8 +244,18 @@ is missing.
194
244
 
195
245
  The same probe also runs when a session opens: the `wsl-preflight` row warns
196
246
  with the full remedy if `bwrap` is unusable, so the news arrives with the
197
- session rather than with the first failed command. It is advisory — it never
198
- installs anything and never changes the fail-closed semantics; drop the
247
+ session rather than with the first failed command. It also checks the session
248
+ runtime's prerequisites — systemd as PID 1, the user manager, `systemd-run` and
249
+ Linger — when `sessionSandbox` on the preflight row is `true` (the default,
250
+ matching the shipped preset). Set `sessionSandbox: false` on that row when the
251
+ profile opts out of the session runtime, so it does not warn about a
252
+ prerequisite the profile does not use.
253
+
254
+ The advisory is logged and also published as a `wsl-preflight` runtime-context
255
+ entry, so it reaches the session transcript; DSH 0.2.1 has no toast/banner seam
256
+ for a plugin (see [UPSTREAM-SESSION-NOTICE.md](UPSTREAM-SESSION-NOTICE.md) and
257
+ [LIMITATIONS.md](LIMITATIONS.md#session-start-advisories)). It is advisory — it
258
+ never installs anything and never changes the fail-closed semantics; drop the
199
259
  `wsl-preflight` row from your layer (or set `sandbox: false` on it) to silence
200
260
  the warning.
201
261
 
@@ -217,8 +277,8 @@ When something refuses, the message names the row or the code:
217
277
  | a command never starts | a `WSL_E_*` code naming the distro | start it with `wsl.exe -d <distro>`, or point `distro` at another one — wider permissions do not help |
218
278
  | `wsl.exe` itself cannot be started | the error names the executable and says to check `wslPath` on this row | fix `wslPath`, and check WSL is installed |
219
279
  | WSL wedges mid-command | no completion within the deadline | retry once, then `wsl.exe --shutdown` and reopen |
220
- | a watched path never fires | `FS_IO_ERROR` from `watch` | watch a path inside the pinned distro |
221
- | a path in another distro is refused | `FS_OUTSIDE_DISTRO` | open a session in that distro, or set `restrictToDistro: false` |
280
+ | a watched path never fires | `FS_IO_ERROR` from `watch` | watch a path inside the selected distro |
281
+ | a path in another distro is refused | `FS_OUTSIDE_DISTRO` | only when `distro` is pinned; set `distro: ''`/`auto` to follow the workspace, or open a session in that distro |
222
282
 
223
283
  The full code table — what each code means and what clears it — is
224
284
  [ARCHITECTURE.md § Error codes](ARCHITECTURE.md#error-codes); the design boundaries
@@ -228,5 +288,5 @@ behind the fail-closed choices above are in [LIMITATIONS.md](LIMITATIONS.md).
228
288
 
229
289
  | Key | Default | Meaning |
230
290
  | --- | --- | --- |
231
- | `distro` | `''` | the distro the `DSH_WSL_*` facts describe |
291
+ | `distro` | `''` | distro mode for the `DSH_WSL_*` facts: empty/`auto` follows each session's workspace, `default` forces WSL's default, and a name pins |
232
292
  | `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 |
@@ -18,8 +18,10 @@ crosses the 9p share. What to know about the one substrate there is:
18
18
  Overwrites and edits keep the original bits. Run `chmod` (or ask the model to)
19
19
  when a new file must be executable.
20
20
  - **When the agent is out, file operations fail closed.** The distro stopped, or
21
- the resident crashed past its one rebuild: `FS_IO_ERROR` naming the substrate,
22
- with the recovery path in the message. There is no fallback I/O to degrade
21
+ the resident crashed through its revive budget: `FS_IO_ERROR` naming the
22
+ substrate, with the recovery path in the message. A single crash rides the
23
+ one rebuild (read-only ops only); a spent rebuild enters the bounded revival
24
+ (2/4/8 s), and three deaths make the state permanent with the restart remedy. There is no fallback I/O to degrade
23
25
  into — deliberately; the old failure message pointed at the share, and the
24
26
  share is what this plugin exists to keep the model off.
25
27
  - **The mutation guard survives to the write.** `createIfAbsent` publishes with
@@ -110,6 +112,37 @@ crosses the 9p share. What to know about the one substrate there is:
110
112
  kernel can actually report change.
111
113
  - `editText` reads the whole file into memory before writing it back.
112
114
 
115
+ ## The GUI workspace-files service
116
+
117
+ - **The GUI file tree and previews use the shipped `workspace-files` row.** The
118
+ plugin deliberately does not replace it: its client half is the browser `file`
119
+ resource provider the Sidebar previews need, and a server-only replacement
120
+ drops it (the earlier `workspace-files-wsl` takeover did exactly that). The
121
+ shipped service reads WSL paths through the routed root filesystem
122
+ (`fs-routing`), so the file tree and previews still run inside the distro.
123
+
124
+ - **That path costs extra agent round trips.** A listing, `stat` or page read
125
+ goes through `ctx.fs` before reaching the resident agent: roughly four to six
126
+ round trips instead of one, and a deep page read streams the preceding lines
127
+ through the pipe before cutting. Expect the GUI file tree and previews to be
128
+ slower than a direct agent call, especially on large files; the model's own
129
+ file operations are unaffected — they use the preset filesystem and the
130
+ resident agent directly. Measured 2026-10-19 with
131
+ [`test/probe/gui-preview.sh`](../test/probe/gui-preview.sh) (20 iterations on
132
+ this machine): the routed backend's `stat` p95 2.7 ms, `listDir` p95 5.1 ms,
133
+ and a 1 MiB `readByteRange` p95 **63.5 ms/MiB** — the service's own round
134
+ trips sit on top of those, and the 1 s/MiB budget has a wide margin. No
135
+ optimization is justified by these numbers; revisit only with a measured
136
+ regression.
137
+
138
+ - **`fs-routing` is the seam.** The shipped service depends on the routed root
139
+ filesystem staying enabled and faithful to the `ctx.fs` contract (`resolve`,
140
+ `lstat`, `stat`, `listDir`, `streamText`, `readBytes`, `watch`, `contains`,
141
+ `processPath`). Disabling `fs-routing` restores the host 9p-backed root
142
+ filesystem, and the GUI file service then crosses 9p for WSL workspaces. The
143
+ shipped row's own caps (`maxBytes`, `maxFileBytes`, `maxLines`, `maxEntries`)
144
+ can be overridden on the `workspace-files` row.
145
+
113
146
  ## The sandbox
114
147
 
115
148
  - **The one-shot fallback has only the host deadline.** With `agent: false`, or
@@ -168,15 +201,47 @@ crosses the 9p share. What to know about the one substrate there is:
168
201
  a separate case and is now reported as an error naming that directory, because
169
202
  `wsl.exe` would otherwise run the command in `/` and exit 0.
170
203
 
204
+ ## Agent diagnostics
205
+
206
+ - **The doctor's `agent.handshake` check probes a fresh resident, not the live
207
+ one.** It starts `agent/wsl-agent.sh` in the distro, reads its HELLO (pid,
208
+ protocol, script digest) and stops it, so it proves that the agent path can
209
+ start and that the file the distro reads is the shipped one. It cannot read
210
+ the running app resident's last error — there is no IPC channel from a plugin
211
+ script to the app process — so that error stays in the app log. In-process,
212
+ `WslAgent.status()` exposes state, pid, lastError, deaths and pending.
213
+ - **`dsh-wsl-*.scope` counts are point-in-time.** The doctor lists and inspects
214
+ the user scopes once; a scope created or stopped a moment later is not
215
+ reflected. `--fix` runs the same sweep the session preflight uses.
216
+
217
+ ## Session-start advisories
218
+
219
+ - **There is no session-UI notice seam in DSH 0.2.1.** When a distro session
220
+ opens, `wsl-preflight` checks `bubblewrap`, systemd as PID 1, the user
221
+ manager, `systemd-run` and Linger. A failure is advisory: it never installs
222
+ anything, never cancels the session and never changes the fail-closed
223
+ behavior of the first command. The finding goes to the host log
224
+ (`logger.warn`) and into the session's runtime context as a `wsl-preflight`
225
+ entry — model-visible on the next assembly — but the GUI today has no
226
+ toast/banner service a plugin can publish to. `pnpm run doctor` is the
227
+ deterministic human-facing check; the exact upstream ask is
228
+ [UPSTREAM-SESSION-NOTICE.md](UPSTREAM-SESSION-NOTICE.md).
229
+ - **The runtime-context entry is a repeated snapshot, not a dismissible
230
+ notification.** It appears in every assembly until the environment is fixed;
231
+ it has no severity decoration and no dismiss control. Removing the
232
+ `wsl-preflight` row (or setting `sandbox: false` on it) silences the probe
233
+ entirely.
234
+
171
235
  ## Ports
172
236
 
173
237
  - **The port poller keeps the resident agent permanently warm.** The
174
238
  `DSH_WSL_PORTS` snapshot refreshes through the shared resident agent every
175
239
  `portsRefreshMs` (10 s by default, well inside the agent's idle timeout), so
176
- while the app runs the agent never idles out: one `wsl.exe` and one
177
- in-distro agent process stay resident per pinned distro even with no command
178
- in flight. Raising `portsRefreshMs` past the agent's idle timeout restores
179
- idle shutdown, at the cost of staler port facts.
240
+ while a distro's session is active the agent never idles out: one `wsl.exe`
241
+ and one in-distro agent process stay resident per active distro even with no
242
+ command in flight. The poller starts only for a distro a session actually
243
+ uses; raising `portsRefreshMs` past the agent's idle timeout restores idle
244
+ shutdown, at the cost of staler port facts.
180
245
 
181
246
  ## The directory picker
182
247
 
@@ -0,0 +1,98 @@
1
+ # Configuration stability and migration
2
+
3
+ What a release may change, how a breaking configuration change is announced and
4
+ carried, and which gates a release must clear. The version table in
5
+ [RELEASING.md](RELEASING.md#1-decide-the-version) is the source for the bump; this
6
+ page is the contract those bumps follow.
7
+
8
+ ## The freeze below 1.0
9
+
10
+ The package is below `1.0.0`, so the on-disk profile format and the peer pins may
11
+ still move in a **minor**. Within that freedom:
12
+
13
+ - **Adding a configuration key is a minor.** The key must have a schema default
14
+ that preserves the previous behavior; a key that changes behavior by existing
15
+ is a major-shaped change and waits.
16
+ - **Removing or renaming a key is a major** *or* a minor with a boot-time refusal
17
+ that names the migration (see below). Silent acceptance of a retired key is
18
+ never allowed: it is the failure the `preset-wsl` trap and the `substrate`
19
+ refusal exist to prevent.
20
+ - **Changing a key's meaning is a minor with a migration note**, because a profile
21
+ written for the old meaning keeps booting. The note must say what changed, how
22
+ to keep the old posture, and where the behavior differs.
23
+ - **Peer pins are exact.** A new upstream peer line is tested by the
24
+ [peer canary](../.github/workflows/peer-canary.yml) and lands as a minor with
25
+ the delta in the release note (or as a patch if the plugin code needs no
26
+ change).
27
+ - **Tool names and error codes are API.** `FS_OUTSIDE_DISTRO`,
28
+ `SANDBOX_UNAVAILABLE`, `FS_STALE_VERSION` and the diagnostic scripts
29
+ (`doctor`, `diagnose`, `preset`, `bootstrap`) keep their names and contracts;
30
+ a rename follows the deprecation path below.
31
+
32
+ ## Deprecating a configuration value
33
+
34
+ 1. **Announce it in the release that introduces the deprecation**: a CHANGELOG
35
+ entry and the row in [CONFIGURATION.md](CONFIGURATION.md). The old value keeps
36
+ working for that release.
37
+ 2. **Keep the old spelling for one minor** when it is unambiguous. When keeping
38
+ it would silently change behavior, refuse it at boot instead and print the
39
+ migration — the `substrate: "share"` case: the value is refused with the
40
+ message that says to delete the line, because serving the share is exactly
41
+ what the plugin must not do.
42
+ 3. **Remove it in the next minor** with another CHANGELOG entry. The release note
43
+ names the version where the refusal appeared.
44
+
45
+ ### Case study: `distro: ''` (0.12.0)
46
+
47
+ `distro: ''` used to mean "WSL's default distro". 0.12.0 made the empty value
48
+ mean **auto**: follow the request workdir's UNC distro. A profile that relied on
49
+ the old meaning keeps booting, but now behaves differently. The migration:
50
+
51
+ ```yaml
52
+ # keep the old posture: every command in WSL's default distro
53
+ - id: preset-wsl
54
+ config:
55
+ plugins:
56
+ # … full block; set on both rows …
57
+ - id: wsl-shell
58
+ config:
59
+ distro: default
60
+ - id: wsl-fs
61
+ config:
62
+ distro: default
63
+ ```
64
+
65
+ `pnpm run preset -- --distro default --out …` writes that block. The CHANGELOG
66
+ entry for 0.12.0 states the change; this page is the durable copy. `doctor`
67
+ reports the effective distro mode in its host/distro rows, and `--dump-config`
68
+ shows the composed value.
69
+
70
+ ## Gates a release must clear
71
+
72
+ | Gate | How | Runs where |
73
+ |---|---|---|
74
+ | Config docs and CHANGELOG current | review the diff; `lint:links` covers cross-references | CI |
75
+ | `pnpm test` | style, links, syntax, build freshness, tsc, 40 unit files | CI (Node 22/24, Ubuntu + Windows) |
76
+ | `pnpm run test:coverage` | lines 85 / branches 84 / functions 70 | CI (Node 24, Ubuntu) |
77
+ | Peer line tested | `peer-canary` workflow resolves `next` and runs the scratch upgrade | CI schedule/manual |
78
+ | Packed install/upgrade/uninstall | `pnpm run e2e:install` (temp `DSH_HOME`, 0.11.0 → current, marker) | manual, this machine (10/10 on 2026-10-19) |
79
+ | Real-machine probe suite | `test/probe/run-all-when-closed.sh` (needs the app closed) | manual; **not run in this environment** since the last batch's targeted probes |
80
+ | Clean Win11 + Ubuntu first run ≤ 5 min | a clean machine, a stopwatch | **not run** (the project's machine is already configured) |
81
+ | aarch64 executed | the native suite on aarch64 WSL or qemu | **not run** (CI builds the binary only) |
82
+ | No open P0 bug for 30 days | issue tracker | time-based; cannot be asserted from a checkout |
83
+
84
+ The last four rows are deliberately marked as unrun rather than assumed: a
85
+ release note must not claim them, and [ROADMAP-1.0.md](ROADMAP-1.0.md#wsl-304--10-readiness)
86
+ tracks them. A release that skips the manual rows still ships through the tag
87
+ workflow, but its notes say which gates were executed.
88
+
89
+ ## Verifying a migration locally
90
+
91
+ ```bash
92
+ pnpm run preset -- --distro default --check <profile>/cordis.patch.yml # no nested-row trap
93
+ pnpm run doctor -- --json # effective environment facts
94
+ dsh --profile <name> --dump-config # composed rows
95
+ ```
96
+
97
+ A profile that fails to boot after a migration should name the remedy in the
98
+ error; if it does not, that is a bug — report it with the `doctor --json` output.
package/docs/PARITY.md CHANGED
@@ -83,7 +83,7 @@ shaped a phase of the plan.
83
83
  | --- | --- | --- | --- |
84
84
  | 0 | landed | This document; contract audit above | baseline recorded |
85
85
  | 1 | landed | `lib/agent.js` (with `lib/agent-protocol.js`, `lib/agent-shared.js`) and `agent/wsl-agent.sh`: resident process speaking a line protocol (pipe-separated fields, single-line base64 payloads), version + script-digest handshake, lazy start, idle shutdown, crash-rebuild-once, one-shot `wsl.exe` fallback for execution | resident server |
86
- | 2 | landed | real `watch()` shipped (in-distro mtime poll, `lib/watcher.js`); the native-ext4 migration of the remaining fs operations finished with the share's retirement — every model-facing file operation now runs on the agent, and the share-era workarounds went with it | native file semantics, change invalidation — watch + publication done (a poll, not inotify; blind spots in LIMITATIONS and CONFIGURATION) |
86
+ | 2 | landed | real `watch()` shipped: inotify for an unbounded directory since 0.10.0 (polling fallback for a file target, a bounded depth or a distro without `inotify-tools`); the native-ext4 migration of the remaining fs operations finished with the share's retirement — every model-facing file operation now runs on the agent, and the share-era workarounds went with it | native file semantics, change invalidation — inotify primary, polling fallback; blind spots in LIMITATIONS and CONFIGURATION |
87
87
  | 3 | landed (deviation: no streaming, no spill files) | `WslShellExecutor` exec via agent; in-distro TERM→KILL; handle shape preserved | native exec latency |
88
88
  | 4 | landed | `npm run bootstrap`, family-aware install command, `SANDBOX_UNAVAILABLE` names it; the handshake ships the protocol version plus the script digest | open-and-it-works |
89
89
  | 5 | landed | per-session routing by workspace coordinates (`terminal-route.js`, `hostSessions`); idle distro terminals are reclaimed by a distro-side scan (`lib/terminal-activity.js`) — the host-side shell-activity integration still does not arm for `wsl.exe`, which is why the observation is a scan | terminal per window/session |
@@ -0,0 +1,60 @@
1
+ # Performance: ext4 agent vs the 9p share
2
+
3
+ The plugin's reason to exist is not only correctness: a WSL workspace served
4
+ through the Windows 9p share is measurably slower than the same files read on
5
+ the distro's ext4 through the resident agent. This page records the measurements
6
+ and how to reproduce them; it does not extrapolate beyond them.
7
+
8
+ ## The two substrates
9
+
10
+ | | 9p share (`\\wsl.localhost\...`) | This plugin |
11
+ |---|---|---|
12
+ | File I/O | host stack crosses the VM boundary per operation | resident agent serves reads/writes on ext4 inside the distro |
13
+ | Change feed | polling the share | inotify inside the distro (`watchMaxDepth: 0`) |
14
+ | Commands | host process | distro login shell through `wsl.exe` |
15
+
16
+ The plugin's root filesystem routes every WSL path to the distro backend
17
+ (`fs-routing`), so the GUI file tree, previews and the model's file tools all
18
+ use the agent path. The design and its trade-offs are in
19
+ [root-fs-routing.md](root-fs-routing.md); the known limits are in
20
+ [LIMITATIONS.md](LIMITATIONS.md).
21
+
22
+ ## Measured numbers
23
+
24
+ Machine: Windows 11 + WSL 2, Ubuntu, x86_64, the project's development machine.
25
+ Absolute numbers are machine-dependent; the ratio and the budgets are the point.
26
+
27
+ | Scenario | 9p share | Agent (ext4) | Source |
28
+ |---|---|---|---|
29
+ | 16 concurrent shared reads | 342–614 ms (queue up to 583 ms) | **2.4–4.1 ms** after A7, first start ~70 ms once | [ADR 0002 fs queue](adr/0002-native-agent.md#fs-queue-measurement-a6-2026-10-18) |
30
+ | Single FS op (host round trip) | — | p95 ≈ 22 ms | [ROADMAP-1.0.md](ROADMAP-1.0.md#7-metrics) |
31
+ | GUI-preview backend: `stat` | — | p95 **2.7 ms** | `pnpm run probe:gui-preview` (20 iterations, 2026-10-19) |
32
+ | GUI-preview backend: listing | — | p95 **5.1 ms** | same |
33
+ | GUI-preview backend: 1 MiB page | — | p95 **63.5 ms/MiB** | same |
34
+
35
+ The GUI-preview number covers the routed backend operations a preview pays.
36
+ The shipped `workspace-files` service adds its own calls on top; that service
37
+ shape stays qualitative in [LIMITATIONS.md](LIMITATIONS.md). The 1 s/MiB budget
38
+ has a wide margin, so no further optimization (the deferred v6 FS concurrency)
39
+ is justified by these measurements.
40
+
41
+ ## Reproduce
42
+
43
+ Inside the distro, with the checkout:
44
+
45
+ ```bash
46
+ pnpm run probe:gui-preview # stat / listDir / 1 MiB page read, p50/p95
47
+ node test/probe/fs-queue.mjs # the agent queue, sequential vs concurrent, with per-op latency
48
+ ```
49
+
50
+ Both need a working distro and Node; neither boots the harness. The probes print
51
+ the raw samples' p50/p95/max and fail when a budget is exceeded, so a regression
52
+ is a red probe rather than a claim in prose.
53
+
54
+ ## What is not measured
55
+
56
+ - End-to-end GUI paint time (the browser and the service's own work are out of
57
+ scope for these probes).
58
+ - 9p numbers for large files or many small files beyond the A6 sample above;
59
+ the A6 measurement is the recorded baseline, not a re-runnable benchmark.
60
+ - aarch64; the binary is built but not executed in CI.