dsh-plugin-wsl-env 0.12.0 → 0.13.1

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.
@@ -22,35 +22,35 @@ 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
50
  | `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
51
51
  | | `includeHostHome` | `true` *(shipped)* | also list the Windows home directory |
52
52
  | | `maxEntries` | `1000` *(shipped)* | maximum entries listed per directory |
53
- | `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 |
54
54
  | | `shell` | `''` | pin a shell; empty lets `wsl.exe` decide |
55
55
  | | `loginShell` | `true` | whether a pinned shell uses login semantics |
56
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 |
@@ -83,6 +83,21 @@ changes nothing and does not fail: the log carries
83
83
  `patch: entry "wsl-shell" not found` and `patch: entry "wsl-fs" not found`, and the
84
84
  composed config keeps the shipped values.
85
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
+
86
101
  The route that works is to override the `preset-wsl` row as a whole. A row's
87
102
  `config` is replaced whole, never deep-merged, so the replacement has to carry
88
103
  `id`, `order` and the entire `plugins` list — every plugin, the `wsl-env` group
@@ -164,9 +179,43 @@ is out of service and the call rides the one-shot `wsl.exe` path.
164
179
 
165
180
  ## Which distro is used
166
181
 
167
- `distro: ''` means the distro `wsl.exe` treats as default, so an installation with a
168
- single distro needs no configuration. Name one when you have several, or when the
169
- 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.
170
219
 
171
220
  ## Turning the sandbox off
172
221
 
@@ -195,8 +244,18 @@ is missing.
195
244
 
196
245
  The same probe also runs when a session opens: the `wsl-preflight` row warns
197
246
  with the full remedy if `bwrap` is unusable, so the news arrives with the
198
- session rather than with the first failed command. It is advisory — it never
199
- 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
200
259
  `wsl-preflight` row from your layer (or set `sandbox: false` on it) to silence
201
260
  the warning.
202
261
 
@@ -218,8 +277,8 @@ When something refuses, the message names the row or the code:
218
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 |
219
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 |
220
279
  | WSL wedges mid-command | no completion within the deadline | retry once, then `wsl.exe --shutdown` and reopen |
221
- | a watched path never fires | `FS_IO_ERROR` from `watch` | watch a path inside the pinned distro |
222
- | 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 |
223
282
 
224
283
  The full code table — what each code means and what clears it — is
225
284
  [ARCHITECTURE.md § Error codes](ARCHITECTURE.md#error-codes); the design boundaries
@@ -229,5 +288,5 @@ behind the fail-closed choices above are in [LIMITATIONS.md](LIMITATIONS.md).
229
288
 
230
289
  | Key | Default | Meaning |
231
290
  | --- | --- | --- |
232
- | `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 |
233
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
@@ -125,7 +127,13 @@ crosses the 9p share. What to know about the one substrate there is:
125
127
  through the pipe before cutting. Expect the GUI file tree and previews to be
126
128
  slower than a direct agent call, especially on large files; the model's own
127
129
  file operations are unaffected — they use the preset filesystem and the
128
- resident agent directly.
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.
129
137
 
130
138
  - **`fs-routing` is the seam.** The shipped service depends on the routed root
131
139
  filesystem staying enabled and faithful to the `ctx.fs` contract (`resolve`,
@@ -193,15 +201,47 @@ crosses the 9p share. What to know about the one substrate there is:
193
201
  a separate case and is now reported as an error naming that directory, because
194
202
  `wsl.exe` would otherwise run the command in `/` and exit 0.
195
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
+
196
235
  ## Ports
197
236
 
198
237
  - **The port poller keeps the resident agent permanently warm.** The
199
238
  `DSH_WSL_PORTS` snapshot refreshes through the shared resident agent every
200
239
  `portsRefreshMs` (10 s by default, well inside the agent's idle timeout), so
201
- while the app runs the agent never idles out: one `wsl.exe` and one
202
- in-distro agent process stay resident per pinned distro even with no command
203
- in flight. Raising `portsRefreshMs` past the agent's idle timeout restores
204
- 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.
205
245
 
206
246
  ## The directory picker
207
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.
@@ -0,0 +1,125 @@
1
+ # Quickstart
2
+
3
+ Five minutes from a clean Windows + WSL 2 machine to a working WSL session.
4
+ This page is the entry point; every claim links to the document that owns it.
5
+
6
+ ## 1. Prerequisites
7
+
8
+ | Need | How to check | If missing |
9
+ |---|---|---|
10
+ | Windows 10/11 with WSL 2 | `wsl.exe -l -v` shows `VERSION 2` | `wsl.exe --install` |
11
+ | A distro | one line in the list above | `wsl.exe --install -d Ubuntu` |
12
+ | `bubblewrap` | `wsl.exe -d <distro> -- which bwrap` | `wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap` |
13
+ | systemd + Linger (for the session runtime) | `node <plugin>/scripts/doctor.mjs` (installed) or `pnpm run doctor` (checkout) | `[boot] systemd=true` in `/etc/wsl.conf`, `wsl --shutdown`, then `loginctl enable-linger "$USER"`; or set `sessionSandbox: auto`/`false` |
14
+ | Node 22.19+ or 24 | only needed for source/development work | not needed to use the published plugin |
15
+
16
+ The doctor (`scripts/doctor.mjs`, or `pnpm run doctor` from a checkout) covers all
17
+ of the above in one pass and prints a copy-paste remedy per failure. It is read-only unless you pass `--fix` (stale scopes) or
18
+ `--fix --install` (packages, after a confirmation).
19
+
20
+ ## 2. Install
21
+
22
+ From a Windows PowerShell where the DSH CLI is available:
23
+
24
+ ```powershell
25
+ dsh plugin --profile <profile> add dsh-plugin-wsl-env
26
+ dsh --profile <profile> --dump-config # the plugin rows must be there
27
+ ```
28
+
29
+ For a profile that already overrides the preset, keep the override as the full
30
+ `preset-wsl` block — `pnpm run preset --check <patch>` fails loudly when a layer
31
+ targets `wsl-shell`/`wsl-fs` by id.
32
+
33
+ ## 3. Open a WSL workspace
34
+
35
+ In the GUI, open a folder through the picker (`\\wsl.localhost\<distro>\home\<you>\proj`)
36
+ or an UNC path directly. The session's commands, file tools, terminal and previews
37
+ all run in that distro; a Windows folder keeps the normal Windows environment.
38
+ Two distros can be open at once, each with its own environment.
39
+
40
+ ## 4. Verify
41
+
42
+ Ask for a command, e.g. `pwd && uname -a`. Expected: the Linux path and the
43
+ distro kernel. If the first command fails, the error names the remedy; the
44
+ [doctor](CONFIGURATION.md) and the [Troubleshooting table](../README.md#troubleshooting)
45
+ cover the common cases.
46
+
47
+ ## Recipes
48
+
49
+ Commands run in the model's session, inside the distro, from the workspace root.
50
+
51
+ ### Node / TypeScript
52
+
53
+ ```bash
54
+ node -v && pnpm -v
55
+ pnpm install
56
+ pnpm test
57
+ pnpm dev # a dev server survives the command that started it with sessionSandbox: true
58
+ ```
59
+
60
+ The server's port appears in `DSH_WSL_PORTS` on the next snapshot (10 s by
61
+ default); open it from Windows at `http://localhost:<port>`.
62
+
63
+ ### Python
64
+
65
+ ```bash
66
+ python3 -m venv .venv && . .venv/bin/activate
67
+ pip install -r requirements.txt
68
+ python -m pytest
69
+ ```
70
+
71
+ Use the distro's interpreter, not a Windows one: the session's `python3` is the
72
+ one on the distro `PATH` (pyenv/conda shims included).
73
+
74
+ ### Rust
75
+
76
+ ```bash
77
+ cargo build --release
78
+ cargo test
79
+ ```
80
+
81
+ Targets are compiled natively for the distro's architecture (x86_64 or aarch64);
82
+ do not reuse Windows `target/` directories.
83
+
84
+ ### Go
85
+
86
+ ```bash
87
+ go build ./...
88
+ go test ./...
89
+ ```
90
+
91
+ The module cache lives in the distro's `$GOPATH`, so builds stay on ext4.
92
+
93
+ ### Monorepo
94
+
95
+ Open the repository root as the workspace. Each package's commands run from the
96
+ same distro; `pnpm -r`, `turbo`, `nx` and `make` see one Linux filesystem. The
97
+ file watcher uses inotify inside the distro (`watchMaxDepth: 0`). If a change is
98
+ missed, see the watcher bounds in [CONFIGURATION.md](CONFIGURATION.md).
99
+
100
+ ## Compatibility
101
+
102
+ Honest status, not a promise. "Probed" means a real-machine run is recorded in
103
+ this repository; "target" means the design supports it but the leg has not been
104
+ executed here.
105
+
106
+ | Tier | Configuration | Status |
107
+ |---|---|---|
108
+ | 1 | Ubuntu 24.04 or Debian 12, systemd, x86_64 | Probed on Ubuntu (the project's development machine; the real-machine suite is manual). Debian is a Tier 1 target, probe pending |
109
+ | 2 | Other systemd distros, aarch64 | The aarch64 native binary is built by CI but not executed here; aarch64 WSL or qemu is the pending leg |
110
+ | Unsupported | WSL 1, Linux/macOS hosts, Windows without WSL 2 | Not supported; the plugin attaches to `wsl.exe` |
111
+
112
+ With `sessionSandbox: auto` a distro without systemd/Linger still gets the
113
+ per-command `bubblewrap` sandbox and the result reports the narrower scope;
114
+ `true` refuses. See [CONFIGURATION.md](CONFIGURATION.md#the-strict-profile) for
115
+ the sandbox trade-offs and [PERFORMANCE.md](PERFORMANCE.md) for the measured
116
+ latency.
117
+
118
+ ## Demo
119
+
120
+ There is no GIF or screenshot in this repository yet: producing one needs a
121
+ capture of the GUI, which this project has not recorded. The
122
+ [Troubleshooting table](../README.md#troubleshooting) and the probe output
123
+ (`pnpm run probe:sandbox`, `pnpm run probe:gui-preview`) are the reproducible
124
+ evidence in the meantime; the absence is recorded in
125
+ [ROADMAP-1.0.md](ROADMAP-1.0.md#wsl-302--discoverability-and-docs-entry).
package/docs/README.zh.md CHANGED
@@ -80,6 +80,7 @@ wsl.exe -d <发行版> -u root -- apt-get install -y bubblewrap
80
80
 
81
81
  打开一个发行版文件夹作为工作区,例如 `\\wsl.localhost\ubuntu\home\you\project`。
82
82
 
83
+ - 打开哪个发行版的文件夹,就在哪个发行版执行——即使它不是 WSL 的默认发行版。
83
84
  - 命令在发行版登录 shell 中运行。`/home/you/x` 与 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件。
84
85
  - **New terminal** 在同一发行版、同一目录打开 shell。
85
86
  - 模型能看到监听端口,并返回 dev server 的 URL。WSL 2 会把 localhost 转发到 Windows。
@@ -91,18 +92,18 @@ wsl.exe -d <发行版> -u root -- apt-get install -y bubblewrap
91
92
 
92
93
  | 行 | 键 | 默认值 | 作用 |
93
94
  |---|---|---|---|
94
- | `wsl-shell` | `distro` | `''` | 使用的发行版;空表示 WSL 默认 |
95
+ | `wsl-shell` | `distro` | `''` | 发行版模式;空/`auto` 跟随工作区,`default` 强制 WSL 默认 |
95
96
  | | `sandbox` | `true` | 用 `bubblewrap` 约束命令 |
96
97
  | | `sessionSandbox` | `true` | 会话命令跑在同一个 systemd user scope 中 |
97
98
  | | `agentBackend` | `auto` | 发行版内 agent:`auto`、`native` 或 `shell` |
98
99
  | | `maskWindowsDrive` | `false` | 让受限命令看不到 `/mnt` |
99
- | `wsl-fs` | `distro` | `''` | 文件操作使用的发行版 |
100
- | | `restrictToDistro` | `true` | 拒绝属于其他发行版的路径 |
100
+ | `wsl-fs` | `distro` | `''` | 发行版模式;空/`auto` 跟随每个目标路径 |
101
+ | | `restrictToDistro` | `true` | 固定发行版(`default` 或名字)时拒绝属于其他发行版的路径 |
101
102
  | | `sandbox` | `true` | 按策略检查受限写入 |
102
103
  | | `sessionSandbox` | `true` | 受限写入跑在会话 runtime 中 |
103
104
  | | `substrate` | `agent` | 发行版内 agent;`share` 在启动时被拒绝 |
104
105
  | `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
105
- | `subprocess-wsl` | `distro` | `''` | GUI 终端使用的发行版 |
106
+ | `subprocess-wsl` | `distro` | `''` | 发行版模式;空/`auto` 跟随启动目录 |
106
107
 
107
108
  `wsl-shell` 和 `wsl-fs` 位于随包的 `wsl` preset 中,按 id 覆盖无效。要改就覆盖整个 `preset-wsl` 行;配方和其余键见 [docs/CONFIGURATION.md](CONFIGURATION.md)。[`cordis.patch.yml`](../cordis.patch.yml) 是随包值的注释参考。
108
109
 
@@ -174,6 +175,7 @@ pnpm run sync:windows # 更新应用加载的 Windows 侧副本
174
175
  | [docs/ARCHITECTURE.md](ARCHITECTURE.md) | 服务、路径路由、沙箱、错误码 |
175
176
  | [docs/LIMITATIONS.md](LIMITATIONS.md) | 插件不做的事 |
176
177
  | [docs/ROADMAP.md](ROADMAP.md) | 当前计划和推迟项 |
178
+ | [docs/ROADMAP-1.0.md](ROADMAP-1.0.md) | 1.0 与生态优化计划 |
177
179
  | [docs/VALUE.zh.md](VALUE.zh.md) | 价值与取舍 |
178
180
  | [CONTRIBUTING.md](../CONTRIBUTING.md) | 开发循环、质量门、探针 |
179
181
  | [SECURITY.md](../SECURITY.md) | 私下上报漏洞 |