dsh-plugin-wsl-env 0.7.5 → 0.9.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 (92) hide show
  1. package/CHANGELOG.md +345 -8
  2. package/CONTRIBUTING.md +221 -68
  3. package/README.md +50 -35
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +23 -7
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +200 -57
  8. package/docs/CONFIGURATION.md +180 -16
  9. package/docs/LIMITATIONS.md +91 -33
  10. package/docs/PARITY.md +66 -38
  11. package/docs/PEER-PARITY.md +95 -32
  12. package/docs/README.zh.md +49 -31
  13. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  14. package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
  15. package/lib/agent-confined.d.ts +42 -0
  16. package/lib/agent-confined.js +25 -41
  17. package/lib/agent-errors.d.ts +31 -0
  18. package/lib/agent-errors.js +19 -16
  19. package/lib/agent-exec.d.ts +159 -0
  20. package/lib/agent-exec.js +151 -165
  21. package/lib/agent-protocol.d.ts +192 -0
  22. package/lib/agent-protocol.js +76 -100
  23. package/lib/agent-shared.d.ts +27 -0
  24. package/lib/agent-shared.js +12 -12
  25. package/lib/agent.d.ts +353 -0
  26. package/lib/agent.js +536 -552
  27. package/lib/auto-preset.d.ts +110 -0
  28. package/lib/auto-preset.js +157 -141
  29. package/lib/bwrap.d.ts +90 -0
  30. package/lib/bwrap.js +18 -18
  31. package/lib/file-reference-wsl.d.ts +160 -0
  32. package/lib/file-reference-wsl.js +192 -173
  33. package/lib/fs-decisions.d.ts +107 -0
  34. package/lib/fs-decisions.js +64 -60
  35. package/lib/fs-routing.d.ts +182 -0
  36. package/lib/fs-routing.js +173 -116
  37. package/lib/fs-substrate.d.ts +258 -0
  38. package/lib/fs-substrate.js +248 -248
  39. package/lib/fs.d.ts +10 -0
  40. package/lib/fs.js +5 -0
  41. package/lib/fsio-agent.d.ts +307 -0
  42. package/lib/fsio-agent.js +414 -410
  43. package/lib/fsio-text.d.ts +115 -0
  44. package/lib/fsio-text.js +104 -84
  45. package/lib/git-route.d.ts +100 -0
  46. package/lib/git-route.js +46 -47
  47. package/lib/index.d.ts +1003 -0
  48. package/lib/index.js +1106 -1003
  49. package/lib/listing.d.ts +114 -0
  50. package/lib/listing.js +68 -55
  51. package/lib/paths.d.ts +182 -0
  52. package/lib/paths.js +134 -109
  53. package/lib/picker.d.ts +266 -0
  54. package/lib/picker.js +371 -351
  55. package/lib/ports.d.ts +44 -0
  56. package/lib/ports.js +39 -29
  57. package/lib/preflight.d.ts +91 -0
  58. package/lib/preflight.js +94 -0
  59. package/lib/preset-choice.d.ts +33 -0
  60. package/lib/preset-choice.js +15 -7
  61. package/lib/probe-cache.d.ts +51 -0
  62. package/lib/probe-cache.js +43 -29
  63. package/lib/sandbox-core.d.ts +149 -0
  64. package/lib/sandbox-core.js +239 -87
  65. package/lib/sandbox.d.ts +48 -0
  66. package/lib/sandbox.js +4 -4
  67. package/lib/search-exec.d.ts +96 -0
  68. package/lib/search-exec.js +52 -79
  69. package/lib/search-route.d.ts +75 -0
  70. package/lib/search-route.js +30 -31
  71. package/lib/shell-env.d.ts +88 -0
  72. package/lib/shell-env.js +78 -74
  73. package/lib/shell.d.ts +10 -0
  74. package/lib/shell.js +5 -0
  75. package/lib/subprocess.d.ts +313 -0
  76. package/lib/subprocess.js +267 -262
  77. package/lib/terminal-activity.d.ts +98 -0
  78. package/lib/terminal-activity.js +29 -39
  79. package/lib/terminal-route.d.ts +27 -0
  80. package/lib/terminal-route.js +16 -8
  81. package/lib/watcher.d.ts +77 -0
  82. package/lib/watcher.js +120 -126
  83. package/lib/workspace-files-route.d.ts +132 -0
  84. package/lib/workspace-files-route.js +73 -81
  85. package/lib/workspace-files-wsl.d.ts +257 -0
  86. package/lib/workspace-files-wsl.js +311 -251
  87. package/lib/wsl.d.ts +266 -0
  88. package/lib/wsl.js +150 -155
  89. package/locale/preflight/en.json +6 -0
  90. package/locale/preflight/zh.json +6 -0
  91. package/package.json +78 -22
  92. package/scripts/diagnose.sh +92 -0
@@ -6,26 +6,48 @@ profile layer: `$DSH_HOME/profiles/<name>/cordis.patch.yml`. Start from
6
6
  [`cordis.patch.yml`](../cordis.patch.yml) open beside it — that file is the commented
7
7
  reference for every value the plugin ships.
8
8
 
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`
11
+ and `preset-wsl` are top-level rows, so `- id: <row>` in your layer patches them.
12
+ `wsl-shell` and `wsl-fs` are not top-level: they are plugins of the `wsl` agent
13
+ preset, nested inside the `preset-wsl` row's `config.plugins`. A patch that
14
+ targets them by id matches nothing — the loader logs `patch: entry "wsl-shell"
15
+ not found` (a warning, not an error), the profile boots anyway, and the value you
16
+ wrote is not in force. See
17
+ [Overriding the rows inside `preset-wsl`](#overriding-the-rows-inside-preset-wsl).
18
+
9
19
  In the table below, *(shipped)* marks the values set by `cordis.patch.yml`. The rest
10
- are schema defaults, listed because they are the ones worth knowing.
20
+ are schema defaults, listed because they are the ones worth knowing. `wsl-shell-env`
21
+ has its own table at the end of this page.
11
22
 
12
23
  | Row | Key | Default | Meaning |
13
24
  |---|---|---|---|
14
25
  | `wsl-shell` | `distro` | `''` *(shipped)* | distro name; empty means WSL's default distro |
15
26
  | | `shell` | `''` | pin a shell inside the distro; empty means the user's login shell |
16
- | | `loginShell` | `true` *(shipped)* | use `<shell> -lc`, which sources your profile, instead of a bare `-c` |
17
- | | `agent` | `true` | run commands over the resident in-distro agent (one long-lived process; in-distro timeout); any agent failure falls back to the one-shot `wsl.exe` path |
18
- | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
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
+ | | `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
+ | | `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) |
19
30
  | | `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` |
20
31
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
21
32
  | | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
22
33
  | `wsl-fs` | `distro` | `''` *(shipped)* | as above |
23
34
  | | `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 |
24
- | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
35
+ | | `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 |
25
36
  | | `maskWindowsDrive` | `false` | as on `wsl-shell`: confined fs mutations publish through a resident whose profile shadows `/mnt` (same honest limits) |
26
- | | `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 |
37
+ | | `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 |
27
38
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
28
39
  | | `watchMaxDepth` | `0` | how deep the in-distro watch scan walks; `0` walks the whole tree. Bound it (e.g. `4`) when the watched tree holds a `node_modules` — a change below the bound is seen only when another change above it fires the same callback |
40
+ | `fs-routing` | `distro` | `''` *(shipped)* | which distro the root-plane filesystem routes to; empty resolves WSL's default. **A row of its own**, with its own copy of the distro knobs — changing `wsl-fs` does not change this one, nor the other way round |
41
+ | | `wslPath` | `wsl.exe` *(shipped)* | path to `wsl.exe`; used here only to resolve the default distro |
42
+ | | `sandbox` | `true` *(shipped)* | fence routed mutations by `ctx.sandboxPolicy`; same `bwrap` requirement and fail-closed refusal as `wsl-fs`. The routing row reports no `sandboxMode` — `undefined` whether the fence is on or off |
43
+ | | `maskWindowsDrive` | `false` *(shipped)* | as on `wsl-shell`, for routed mutations |
44
+ | | `restrictToDistro` | `true` *(shipped)* | refuse a path in another distro's share (`FS_OUTSIDE_DISTRO`) |
45
+ | | `watchMaxDepth` | `0` *(shipped)* | how deep the routed watch scan walks; `0` walks the whole tree |
46
+ | | `distroCwd` | `''` | the routed backend's default workdir; empty means the distro user's home. `cwd` belongs to the host backend only — the distro side reads `distroCwd` — while `diffBasisMaxBytes` is shared: it bounds the overwrite diff on both sides |
47
+ | `workspace-files-wsl` | `maxBytes` | `2097152` *(shipped)* | byte cap on one page of a text read, and the default byte window of a byte read |
48
+ | | `maxFileBytes` | `33554432` *(shipped)* | full-file byte cap for the byte reader |
49
+ | | `maxLines` | `5000` *(shipped)* | line cap on one text read |
50
+ | | `maxEntries` | `2000` *(shipped)* | directory entries listed per level before the listing is flagged truncated |
29
51
  | `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
30
52
  | | `includeHostHome` | `true` *(shipped)* | also list the Windows home directory |
31
53
  | | `maxEntries` | `1000` *(shipped)* | maximum entries listed per directory |
@@ -35,13 +57,106 @@ are schema defaults, listed because they are the ones worth knowing.
35
57
  | | `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 |
36
58
  | | `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 |
37
59
 
38
- You can override the other keys in the same way; they keep their defaults. Those are
39
- `wslPath`, `hostCwd` and `forwardEnv` on the shell and terminal rows,
40
- `diffBasisMaxBytes` on `wsl-fs`, and `distroCacheMs` on the picker. The shell row
41
- also inherits the shipped output budgets — `maxOutputBytes` (64000 bytes per
42
- stream before the rest spills to a temporary file), `maxSpillBytes` (67108864) and
43
- `graceMs` (3000, the SIGTERM-to-SIGKILL grace) — from `dsh-bash-local`, which
44
- documents them in full.
60
+ ## Overriding the rows inside `preset-wsl`
61
+
62
+ `wsl-shell` and `wsl-fs` are mounted as plugins of the `wsl` agent preset, inside
63
+ the `preset-wsl` row's `config.plugins`. The loader indexes top-level entry ids —
64
+ plus the plugin lists of rows that are themselves groups — and `preset-wsl` is a
65
+ plain entry whose plugins sit inside its `config`, so those two ids are not
66
+ addressable from a patch layer at all. A layer of
67
+
68
+ ```yaml
69
+ - id: wsl-shell
70
+ config:
71
+ distro: Ubuntu
72
+
73
+ - id: wsl-fs
74
+ config:
75
+ restrictToDistro: false
76
+ ```
77
+
78
+ changes nothing and does not fail: the log carries
79
+ `patch: entry "wsl-shell" not found` and `patch: entry "wsl-fs" not found`, and the
80
+ composed config keeps the shipped values.
81
+
82
+ The route that works is to override the `preset-wsl` row as a whole. A row's
83
+ `config` is replaced whole, never deep-merged, so the replacement has to carry
84
+ `id`, `order` and the entire `plugins` list — every plugin, the `wsl-env` group
85
+ and everything after it. Copy the whole `- id: preset-wsl` block out of
86
+ [`cordis.patch.yml`](../cordis.patch.yml) (or out of
87
+ `dsh --profile <name> --dump-config`), paste it into your layer, and edit only the
88
+ values you want:
89
+
90
+ ```yaml
91
+ - id: preset-wsl
92
+ config:
93
+ id: wsl
94
+ order: 2
95
+ plugins:
96
+ - id: wsl-env
97
+ name: cordis:group
98
+ group: true
99
+ isolate:
100
+ shell: true
101
+ fs: true
102
+ config:
103
+ - id: wsl-shell
104
+ name: 'dsh-plugin-wsl-env'
105
+ config:
106
+ distro: Ubuntu # ← the edit
107
+ loginShell: true
108
+ timeoutMs: 120000
109
+ maxTimeoutMs: 600000
110
+ - id: wsl-fs
111
+ name: 'dsh-plugin-wsl-env/fs'
112
+ config:
113
+ distro: ''
114
+ restrictToDistro: false # ← the edit
115
+ substrate: agent
116
+ # … and the rest of the group, verbatim — do not leave a plugin out …
117
+ ```
118
+
119
+ Leaving a plugin out of the replacement drops it: the preset would mount only the
120
+ plugins you listed. The keys described above are the only reason to rewrite this
121
+ row, so treat the `preset-wsl` block as one unit and re-copy it after a plugin
122
+ upgrade that changes it.
123
+
124
+ For the root-plane filesystem — the row that serves `ctx.fs` outside any session,
125
+ `fs-routing` — no rewrite is needed: it is a top-level row, and
126
+ `restrictToDistro`, `sandbox` and `maskWindowsDrive` can be set on it directly.
127
+ That governs different traffic from `wsl-fs`; see
128
+ [`docs/root-fs-routing.md`](root-fs-routing.md).
129
+
130
+ ## Other keys
131
+
132
+ You can override the other keys in the same way, with one split: every row in the
133
+ table below except `wsl-shell` and `wsl-fs` is top-level, so `- id: <row>` in your
134
+ own layer patches it — those two are inside `preset-wsl` and take the rewrite
135
+ above. They all keep their defaults:
136
+
137
+ | Row | Key | Default | Meaning |
138
+ |---|---|---|---|
139
+ | `wsl-shell` (in `preset-wsl`) | `wslPath` | `wsl.exe` | path to `wsl.exe`, for an installation that is not on `PATH` |
140
+ | | `hostCwd` | unset → `%SystemRoot%` | the Windows directory the `wsl.exe` process itself starts in |
141
+ | | `forwardEnv` | `[]` | extra host environment variable names to forward into the distro through `WSLENV`. This is the one key here that widens what crosses the boundary: `NO_COLOR`, `TERM`, `PAGER`, `GIT_PAGER`, `LANG`, `LC_ALL` and every managed `DSH_*` name ride along without it, and `PATH` is deliberately never forwarded |
142
+ | `wsl-fs` (in `preset-wsl`) | `wslPath` | `wsl.exe` | as above; used only to resolve the default distro |
143
+ | | `diffBasisMaxBytes` | `10485760` | UTF-8 byte limit per side of an overwrite diff; a larger file publishes without a diff |
144
+ | `fs-routing` | `cwd`, `diffBasisMaxBytes` | `process.cwd()`, `10485760` | `cwd` configures the host side only (the distro side reads `distroCwd`); `diffBasisMaxBytes` is shared by both — it also bounds the routed backend's overwrite diff |
145
+ | `directory-picker-wsl` | `wslPath` | `wsl.exe` | as above |
146
+ | | `distroCacheMs` | `5000` | how long a resolved distro list stays fresh before `wsl.exe` is asked again |
147
+ | `subprocess-wsl` | `wslPath` | `wsl.exe` | as above |
148
+ | | `cwd` | `''` | fallback directory when a launch names none |
149
+ | | `hostCwd` | unset → `%SystemRoot%` | as on `wsl-shell` |
150
+ | | `forwardEnv` | `[]` | as on `wsl-shell`, for terminal launches |
151
+
152
+ The shell row also inherits the shipped output budgets from `dsh-bash-local`,
153
+ which documents them in full: `maxOutputBytes` (64000), `maxSpillBytes`
154
+ (67108864) and `graceMs` (3000, the SIGTERM-to-SIGKILL grace). The spill keys
155
+ belong to the one-shot path: with `agent: true` — the default — the capture is
156
+ cut at `maxOutputBytes` per stream inside the distro, the result reports
157
+ `truncated: true`, and no temporary file is written, so `maxSpillBytes` and
158
+ `graceMs` have nothing to bound. They apply when `agent` is `false` or the agent
159
+ is out of service and the call rides the one-shot `wsl.exe` path.
45
160
 
46
161
  ## Which distro is used
47
162
 
@@ -53,9 +168,58 @@ default is not the one you want sessions in.
53
168
 
54
169
  `sandbox: false` on `wsl-shell` and `wsl-fs` runs commands and writes without
55
170
  confinement. `sandboxMode` then reports `undefined`, and the tool layer tells the
56
- model that these operations have no sandbox. That is the documented opt-out; see
57
- [ARCHITECTURE.md](ARCHITECTURE.md#sandbox) for what the sandbox does and does not
58
- cover.
171
+ model that these operations have no sandbox. `fs-routing` takes the same key and
172
+ drops the fence on the root-plane filesystem the same way, but that service reports
173
+ no `sandboxMode` either way — it inherits the base class's empty getter — so the
174
+ key decides the fence, not a capability fact the model reads. That is the documented
175
+ opt-out; see [ARCHITECTURE.md](ARCHITECTURE.md#sandbox) for what the sandbox does
176
+ and does not cover.
177
+
178
+ The default posture needs `bubblewrap` **inside the distro** and fails closed: a
179
+ distro without a usable `bwrap` refuses the command (`SANDBOX_UNAVAILABLE`) or the
180
+ write (`FS_IO_ERROR`, the message carrying the same guidance) instead of
181
+ running it unconfined. The refusal distinguishes the two causes — `bwrap`
182
+ missing (install it) from `bwrap` present but failing (usually the kernel's
183
+ unprivileged user namespaces; reinstalling will not help) — and the direct
184
+ install command it prints follows the distro's own package family
185
+ (apt/dnf/pacman/zypper). A failed probe is re-run,
186
+ so installing `bubblewrap` takes effect on the next command without a restart.
187
+ Install it with `scripts/bootstrap.sh <distro> --install`, or directly:
188
+ `wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap`. Run without
189
+ `--install`, `scripts/bootstrap.sh <distro>` is a read-only check that prints what
190
+ is missing.
191
+
192
+ The same probe also runs when a session opens: the `wsl-preflight` row warns
193
+ with the full remedy if `bwrap` is unusable, so the news arrives with the
194
+ session rather than with the first failed command. It is advisory — it never
195
+ installs anything and never changes the fail-closed semantics; drop the
196
+ `wsl-preflight` row from your layer (or set `sandbox: false` on it) to silence
197
+ the warning.
198
+
199
+ ## Checking a change, and what refuses
200
+
201
+ `dsh --profile <name> --dump-config` composes the profile and prints the result
202
+ without booting. It is the fastest way to see whether a row you wrote landed —
203
+ including the warning that says it did not — and for the rows inside `preset-wsl`
204
+ the composed tree it prints carries the block to copy back into your layer.
205
+ `scripts/bootstrap.sh <distro>` does the same for the distro-side prerequisites.
206
+
207
+ When something refuses, the message names the row or the code:
208
+
209
+ | Symptom | What you see | What clears it |
210
+ |---|---|---|
211
+ | commands fail with no usable sandbox | `SANDBOX_UNAVAILABLE` — the text says whether `bwrap` is missing or present but failing, with the matching remedy | missing: install `bubblewrap` (`scripts/bootstrap.sh <distro> --install`, or the printed family command); present-but-failing: check `bwrap --version` and the kernel's unprivileged user namespaces, or set `sandbox: false` on `wsl-shell` |
212
+ | writes fail with no usable sandbox | `FS_IO_ERROR`, with the same `bwrap` remedy the commands carry | the same install, or `sandbox: false` on `wsl-fs` (`fs-routing` for root-plane writes) |
213
+ | file tools fail while the distro is down | `FS_IO_ERROR`, "the distro file substrate is unavailable" | start the distro (`wsl.exe -l -v`), restart the session if it stays out |
214
+ | 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 |
215
+ | `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 |
216
+ | WSL wedges mid-command | no completion within the deadline | retry once, then `wsl.exe --shutdown` and reopen |
217
+ | a watched path never fires | `FS_IO_ERROR` from `watch` | watch a path inside the pinned distro |
218
+ | a path in another distro is refused | `FS_OUTSIDE_DISTRO` | open a session in that distro, or set `restrictToDistro: false` |
219
+
220
+ The full code table — what each code means and what clears it — is
221
+ [ARCHITECTURE.md § Error codes](ARCHITECTURE.md#error-codes); the design boundaries
222
+ behind the fail-closed choices above are in [LIMITATIONS.md](LIMITATIONS.md).
59
223
 
60
224
  ## `wsl-shell-env`
61
225
 
@@ -29,13 +29,21 @@ crosses the 9p share. What to know about the one substrate there is:
29
29
  syscall before the rename: a concurrent writer wins and the stale write
30
30
  refuses with `FS_STALE_VERSION`. The residual stat-then-rename pair is the
31
31
  same sliver a Linux-host session's own rename has.
32
+ - **A write is durable against process death, not machine death.** The
33
+ publication fsyncs the staged file before its atomic rename, but the
34
+ containing directory is not fsynced afterwards: a kernel panic or power loss
35
+ immediately after a successful write can lose the rename, and the file
36
+ reverts to its pre-write content. The peer backend fsyncs its staged file the
37
+ same way, but neither fsyncs the containing directory — the exposure is the
38
+ peer's too, and it stays short of a crash-consistent store.
32
39
  - **A Linux filename containing a backslash cannot be addressed.** The UNC
33
40
  display form (`\\wsl.localhost\<distro>\...`) is the file's identity
34
41
  everywhere - the session header, the GUI, the caches - and the backslash is
35
42
  the UNC's separator, so `a\b.ts` in the distro round-trips to `a/b.ts`: the
36
43
  tools would silently address a different path. This is the coordinate
37
- system's own limit (the peer's UNC provider has it too), not a defect of
38
- the substrate; a rename away from backslash names is the workaround.
44
+ system's own limit — a backslash-delimited identity cannot spell a name
45
+ containing a backslash — not a defect of the substrate; a rename away from
46
+ backslash names is the workaround.
39
47
  - **Stage two routes mutations through a confined resident.** A write or edit
40
48
  under a confined policy runs on its own long-lived agent, spawned inside the
41
49
  bwrap profile that binds exactly what the mode grants — so the kernel, not
@@ -45,32 +53,27 @@ crosses the 9p share. What to know about the one substrate there is:
45
53
  refuses them with the bootstrap command, rather than downgrading); a policy
46
54
  change addresses a different resident, so the first write after switching the
47
55
  Permissions selector pays one agent startup; and a write the policy grants to
48
- the distro's `/tmp` lands in the confined mount's tmpfs, not the real `/tmp`
49
- — the same hand-off rule as the commands' `/tmp`: pass files through the
50
- session workspace.
56
+ the distro's `/tmp` lands in the distro's real `/tmp`, because the
57
+ file-writes resident binds it read-write — where the read tools, the plain
58
+ resident and the user's own shell all see it. The ephemeral tmpfs belongs to
59
+ the command side alone, so a file handed over from a command still goes
60
+ through the session workspace.
51
61
  - **`glob` and `grep` run the distro's own rg** (`lib/search-route.js`): the
52
62
  tool's arguments are forwarded verbatim, so results, output format and exit
53
63
  codes are rg's own — at ext4 speed, with the distro's `.gitignore` rules.
54
- A distro without rg surfaces rg's own "command not found" (exit 127);
55
- `npm run bootstrap -- <distro> --install` installs it. A search whose
56
- directory is a Windows folder keeps the host binary, which is native there.
64
+ A distro without rg fails the search outright: exit 127, with the message
65
+ from the exec launcher and never from rg itself (which is the missing
66
+ program) — `setsid: failed to execute rg: No such file or directory` where
67
+ the distro has `setsid`, the script shell's `rg: not found` where it does
68
+ not. The install command `scripts/bootstrap.sh` prints — `wsl.exe -d <distro>
69
+ -u root -- apt-get install -y bubblewrap git ripgrep inotify-tools`, or the
70
+ detected family's spelling — installs it. `--install` runs that command only
71
+ when `bubblewrap` itself is missing, so a distro that has bwrap but not rg
72
+ needs the printed line run by hand. A search whose directory is a Windows
73
+ folder keeps the host binary, which is native there.
57
74
  - **`watch()` is one poll loop** (a `find -newer` scan inside the distro), so
58
75
  the coarse-invalidation caveats below apply. An inotify backend is planned,
59
76
  not shipped.
60
-
61
- - **The sandbox does not govern WSL interop.** A confined command can still run a
62
- Windows program, which escapes the Linux-side boundary. The plugin reports this as
63
- `enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
64
- The `maskWindowsDrive` key on `wsl-shell` and `wsl-fs` narrows the hole: the
65
- confined profile shadows `/mnt` with an empty tmpfs, so the drive's files cannot be
66
- read or exfiltrated and its executables cannot be launched. It is not a closure — a
67
- command that can write the workspace can write an executable there and run it
68
- (binfmt interop dispatches on file content, not location) — so enforcement stays
69
- `partial`, the result's sandbox fact reports `windowsDrive: "masked"`, and the only
70
- complete closure remains distro-level (`[interop] enabled=false` in `wsl.conf`).
71
- - **`bubblewrap` must be installed**, or both providers fail immediately. The
72
- usability probe re-runs after a failure, so installing bubblewrap while the app
73
- runs is believed on the next command; only a success is cached.
74
77
  - **A confined command's `/tmp` is not the write tool's `/tmp`.** `workspace-write`
75
78
  mounts a fresh tmpfs at `/tmp` for the command — that is what makes it ephemeral —
76
79
  while the file tools' fence grants the distro's *real* `/tmp`. Both are writable, so
@@ -78,25 +81,77 @@ crosses the 9p share. What to know about the one substrate there is:
78
81
  `bash` and vice versa. Hand a file between them through the session workspace.
79
82
  - A distro that is stopped or unregistered while the app runs takes the file
80
83
  tools' substrate with it: reads and writes report the substrate-unavailable
81
- error naming the cause, and recover on the next call once the distro starts.
84
+ error naming the cause. A death with nothing in flight costs nothing — the
85
+ next call starts a fresh transport, so a distro that comes back is served
86
+ again. A death with a request in flight spends the resident's one permitted
87
+ rebuild, and when that rebuild cannot complete — the distro is still down, or
88
+ gone — the resident is permanently out: every later file operation fails the
89
+ same way until the app restarts.
90
+ - `watch()` observes from inside the distro (a `find -newer` poll loop, see
91
+ `lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
92
+ and they arrive on the poll cadence, not instantly. A creation is seen even when
93
+ the new file's own mtime is old: creating it bumps the containing directory's
94
+ mtime, and the scan includes the watched directory itself (deletions are seen the
95
+ same way). What escapes the stamp is a change that leaves every scanned mtime
96
+ older than it — an in-place `cp -p` onto an existing file (new content, a
97
+ copied-back mtime), or a `tar -x` whose archive restores the containing
98
+ directory's own mtime. Watching a 9p share from the Windows side remains
99
+ unsupported — the loop runs where the kernel can actually report change.
100
+ - `editText` reads the whole file into memory before writing it back.
101
+
102
+ ## The sandbox
103
+
104
+ - **The sandbox does not govern WSL interop.** A confined command can still run a
105
+ Windows program, which escapes the Linux-side boundary. The plugin reports this as
106
+ `enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
107
+ The `maskWindowsDrive` key on `wsl-shell`, `wsl-fs` and `fs-routing` narrows the
108
+ hole: the confined profile shadows `/mnt` with an empty tmpfs, so the drive's files
109
+ cannot be read or exfiltrated and its executables cannot be launched. It is not a
110
+ closure — a command that can write the workspace can write an executable there and
111
+ run it (binfmt interop dispatches on file content, not location) — so enforcement
112
+ stays `partial`, the result's sandbox fact reports `windowsDrive: "masked"`, and
113
+ the only complete closure remains distro-level (`[interop] enabled=false` in
114
+ `wsl.conf`).
115
+ - **`bubblewrap` must be installed**, or both providers fail immediately. The
116
+ usability probe re-runs after a failure, so installing bubblewrap while the app
117
+ runs is believed on the next command; only a success is cached. The refusal
118
+ classifies the probe's failure — missing versus present-but-failing — and the
119
+ direct install command follows the distro's package family; a probe failure
120
+ with no recognisable shape falls back to the install remedy with the raw
121
+ output quoted. A session-start preflight (`wsl-preflight`) surfaces the same
122
+ text as a warning; it is advisory and never installs.
82
123
  - `workspace-write` binds the workspace root as writable, and bubblewrap refuses a
83
124
  bind whose source directory does not exist, so a session whose workspace directory was
84
125
  deleted fails with a runner error; the plugin does not recreate it. A workdir that
85
126
  does not exist — a configured `cwd` pointing at a deleted directory, for instance — is
86
127
  a separate case and is now reported as an error naming that directory, because
87
128
  `wsl.exe` would otherwise run the command in `/` and exit 0.
88
- - `watch()` observes from inside the distro (a `find -newer` poll loop, see
89
- `lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
90
- and they arrive on the poll cadence, not instantly. A file created with an mtime
91
- older than the watcher's stamp (`cp -p`, `tar -x`) is not seen until something
92
- else touches the tree. Watching a 9p share from the Windows side remains
93
- unsupported — the loop runs where the kernel can actually report change.
94
- - `editText` reads the whole file into memory before writing it back.
129
+
130
+ ## Ports
131
+
132
+ - **The port poller keeps the resident agent permanently warm.** The
133
+ `DSH_WSL_PORTS` snapshot refreshes through the shared resident agent every
134
+ `portsRefreshMs` (10 s by default, well inside the agent's idle timeout), so
135
+ while the app runs the agent never idles out: one `wsl.exe` and one
136
+ in-distro agent process stay resident per pinned distro even with no command
137
+ in flight. Raising `portsRefreshMs` past the agent's idle timeout restores
138
+ idle shutdown, at the cost of staler port facts.
139
+
140
+ ## The directory picker
141
+
95
142
  - The directory picker lists distro levels from inside the distro — the
96
143
  resident's `ls -1ALp` in one round trip, a one-shot `wsl.exe --exec ls` when
97
144
  it is out, never the 9p walk the host used to pay per entry. The listing is
98
- line-parsed, so a filename containing a newline would render as two rows;
99
- `/` cannot appear in a name, so nothing else can split one.
145
+ line-parsed and only a line that ends in `/` becomes a row, so a directory
146
+ name containing a newline cannot be listed correctly: `ls` emits `a\nb` as
147
+ the two lines `a` and `b/`, and the picker offers the single row `b` at
148
+ `<parent>/b` — the part after the newline, a different directory or none at
149
+ all. A name that ends in a newline yields no row at all: the split leaves one
150
+ segment without a trailing `/` and one empty name, and the parser keeps
151
+ neither. `/` cannot appear in a name, so nothing else can split one.
152
+
153
+ ## Terminals
154
+
100
155
  - Terminal routing is per-session by workspace coordinates (`subprocess-wsl.hostSessions`,
101
156
  default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
102
157
  `powershell.exe` in its own directory. The shell MENU remains the single configured
@@ -124,6 +179,9 @@ crosses the 9p share. What to know about the one substrate there is:
124
179
  the idle threshold is the controller's `unattendedTimeoutMs` (2 h by
125
180
  default); and the host-side shell-activity integration is still not armed
126
181
  for these launches — `wsl.exe` on win32 fails its gate — which is why the
127
- observation is a scan rather than shell cooperation.
182
+ observation is a scan rather than shell cooperation. `terminalIdleReclaim:
183
+ false` on the `subprocess-wsl` row restores close-by-hand: the launch then
184
+ carries no `DSH_TERMINAL_ID` marker, and the handle keeps the shipped
185
+ `unknown` activity, which never accumulates idle.
128
186
  - Windows-folder host terminals (the `powershell.exe` branch) keep the shipped
129
187
  behaviour: `unknown` activity, no reclamation.
package/docs/PARITY.md CHANGED
@@ -1,18 +1,25 @@
1
1
  # Parity with VS Code Remote-WSL
2
2
 
3
3
  What parity means for this plugin, the seam contracts the plan was audited
4
- against, and the phase checklist. Every phase below ends with rows of this
5
- table ticked and this document updated in the same change.
4
+ against, and the phase checklist with each phase's current status. The plan
5
+ has landed; the rows below record where each phase ended up and what
6
+ deviation, if any, still stands.
7
+
8
+ Verified against: `dsh-plugin-wsl-env@0.8.0` / peer `0.2.0-rc.2` / agent
9
+ protocol v4, 2026-10-06. Update this line when the peer pin or the agent
10
+ protocol moves.
6
11
 
7
12
  ## What parity means here
8
13
 
9
14
  Remote-WSL's experience rests on one thing: a resident server inside the
10
15
  distro. Everything users love — native ext4 semantics, inotify, native
11
16
  `rg`, low latency — is a derivative. This plugin's 9p-UNC plus one-shot
12
- `wsl.exe` model can only approach it. The plan therefore lands a resident
13
- in-distro agent and migrates the `fs` and `shell` seams onto it, keeping the
14
- current paths as the documented fallback (`wsl.agent: false` must equal
15
- today's behaviour byte for byte).
17
+ `wsl.exe` model can only approach it, so the plan landed a resident
18
+ in-distro agent and moved the `fs` and `shell` seams onto it. The agent is
19
+ now the only substrate for the file tools; the fallbacks left are at the
20
+ transport level — the one-shot `wsl.exe` path that `wsl-shell.agent: false`
21
+ pins for command execution, and that a search spawn drops to when the agent
22
+ is out, never a share.
16
23
 
17
24
  Not applicable, recorded so the gap is a decision and not an omission:
18
25
 
@@ -21,12 +28,12 @@ Not applicable, recorded so the gap is a decision and not an omission:
21
28
  - Settings Sync, Remote-SSH tunnel chaining, Codespaces: composition-layer
22
29
  responsibilities, not this plugin's.
23
30
 
24
- > **Update (2026-10-04).** The plan's transitional invariant — `wsl.agent:
25
- > false` must equal the pre-agent share-backed behaviour — is superseded: the
26
- > share substrate is refused at construction, and the search spawn rides the
27
- > distro's rg. The share is no longer a fallback path of this plugin; the
28
- > only fallbacks left are transport-level (one-shot `wsl.exe` where the
29
- > resident agent is out), never share I/O.
31
+ > **Update (2026-10-04).** The plan's transitional invariant — that
32
+ > `wsl-shell.agent: false` must equal the pre-agent share-backed behaviour —
33
+ > is superseded: the share substrate is refused at construction, and the
34
+ > search spawn rides the distro's rg. The share is no longer a fallback path
35
+ > of this plugin; the only fallbacks left are transport-level (one-shot
36
+ > `wsl.exe` where the resident agent is out), never share I/O.
30
37
 
31
38
  ## Contract audit (Phase 0)
32
39
 
@@ -47,17 +54,18 @@ shaped a phase of the plan.
47
54
  distro's share, rewrite the spawn to run a distro-side `rg` (falling back
48
55
  to `grep -rE` + `find` when absent) with identical stdout conventions.
49
56
  Under the agent, that exec rides the agent's `exec` capability.
50
- **LANDED (2026-10-04), as the design above has it:** `WslSubprocessRuntime`
51
- .`spawn` performs the rewrite, the decision living in `lib/search-route.js`,
52
- and the exec rides the agent's `exec` capability — Phase 1's resident —
53
- through the minimal handle facade of `lib/search-exec.js` (the spawn seam
54
- returns its handle synchronously, so the facade degrades inside `done`),
55
- with the one-shot `wsl.exe --exec` handle as the fallback when the agent is
56
- out. The `grep -rE` fallback is NOT implemented — grep does not read
57
- `.gitignore`, so it is not an rg substitute; a missing distro rg surfaces
58
- rg's own exit 127 with a bootstrap pointer instead. With the share
59
- substrate retired the same day, no model-facing search crosses the 9p
60
- share at all.
57
+ **LANDED (2026-10-04), as the design above has it:**
58
+ `WslSubprocessRuntime.spawn` performs the rewrite, the decision living in
59
+ `lib/search-route.js`, and the exec rides the agent's `exec` capability —
60
+ Phase 1's resident — through the minimal handle facade of
61
+ `lib/search-exec.js` (the spawn seam returns its handle synchronously, so
62
+ the facade degrades inside `done`), with the one-shot `wsl.exe --exec`
63
+ handle as the fallback when the agent is out. The `grep -rE` fallback is
64
+ NOT implemented — grep does not read `.gitignore`, so it is not an rg
65
+ substitute; a distro without one surfaces rg's own "command not found",
66
+ and `scripts/bootstrap.sh` reports whether the binary is installed. With
67
+ the share substrate retired the same day, no model-facing search crosses
68
+ the 9p share at all.
61
69
  3. **`dsh-fs-local` watches with chokidar over the share** — the behaviour
62
70
  this plugin replaces: chokidar on 9p is the unreliability that made
63
71
  `WslFileSystem.watch` refuse. The in-distro watcher replaces it wholesale.
@@ -69,21 +77,41 @@ shaped a phase of the plan.
69
77
 
70
78
  ## Phase checklist
71
79
 
72
- | Phase | Deliverable | Parity row |
73
- | --- | --- | --- |
74
- | 0 | This document; contract audit above | baseline recorded |
75
- | 1 | `lib/agent/` + `wsl-agent` resident process: NDJSON-over-stdio protocol, capability handshake, lazy start, idle shutdown, crash-rebuild-once, permanent fallback | resident server |
76
- | 2 | real `watch()` SHIPPED (in-distro poll, `lib/watcher.js`); the native-ext4 migration of the remaining fs operations (and with it the workaround deletions) continues alongside Phase 3 | native file semantics, inotify — watch + publication done |
77
- | 3 | DONE — `WslShellExecutor` exec via agent; in-distro TERM→KILL; handle shape preserved (deviation: no streaming, no spill files) | native exec latency |
78
- | 4 | DONE (except the agent/plugin version handshake, which lands with the first agent-behaviour change after release) — `npm run bootstrap`, family-aware install command, `SANDBOX_UNAVAILABLE` names it | open-and-it-works |
79
- | 5 | DONE (routing half) — per-session routing by workspace coordinates (`terminal-route.js`, `hostSessions`); activity-based idle reclaim of distro terminals remains a documented limitation (PTY activity stops at `wsl.exe`) | terminal per window/session — routing done |
80
- | 6 | DONE — `DSH_WSL_PORTS` (agent-scanned `/proc/net/tcp{,6}`, timer-refreshed); localhost reachability (WSL2 localhost forwarding is platform behaviour) documented in README | port forwarding visibility |
81
- | 7 | DONE — recipes section in both READMEs: credential sharing, `/mnt/c` guidance, `WSLENV` policy | developer-loop odds and ends |
80
+ | Phase | Status | Deliverable | Parity row |
81
+ | --- | --- | --- | --- |
82
+ | 0 | landed | This document; contract audit above | baseline recorded |
83
+ | 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 |
84
+ | 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) |
85
+ | 3 | landed (deviation: no streaming, no spill files) | `WslShellExecutor` exec via agent; in-distro TERM→KILL; handle shape preserved | native exec latency |
86
+ | 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 |
87
+ | 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 |
88
+ | 6 | landed | `DSH_WSL_PORTS` (agent-scanned `/proc/net/tcp{,6}`, timer-refreshed); localhost reachability (WSL2 localhost forwarding is platform behaviour) documented in README | port forwarding visibility |
89
+ | 7 | landed | recipes section in both READMEs: credential sharing, `/mnt/c` guidance, `WSLENV` policy | developer-loop odds and ends |
82
90
 
83
91
  ## Fallback invariant
84
92
 
85
- Every phase must keep `wsl.agent: false` (and, per capability, any runtime
86
- fallback) behaviourally identical to the pre-phase release. Probes assert
87
- this: `agent.sh` runs the same operations through the resident and through
88
- the one-shot `wsl.exe` path and compares outcomes — exit code, stdout bytes,
89
- stderr bytes, working directory — not internals.
93
+ The plan's rule — every phase keeps the pre-phase behaviour byte for byte —
94
+ is superseded by the Update above. What is still load-bearing is narrower,
95
+ and no longer involves the share:
96
+
97
+ - **Command execution.** `wsl-shell.agent: false`, and any agent failure at
98
+ runtime, run the command through the one-shot `wsl.exe` path, which is the
99
+ pre-agent `executeOneShot` verbatim; confinement and result decoration are
100
+ identical on both, and the budgets keep their shapes — the agent path cuts
101
+ each stream at `maxOutputBytes` inside the distro and reports
102
+ `truncated: true` with no spill file, where the one-shot path can spill
103
+ (`maxSpillBytes`, `graceMs`). The probe asserts this:
104
+ `pnpm run probe:agent` (`bash test/probe/agent.sh`, Windows + WSL only)
105
+ runs the same operations through the resident and through the one-shot path
106
+ and compares outcomes — exit code, stdout bytes, stderr bytes, working
107
+ directory — not internals.
108
+ - **Search has no share fallback either.** The distro rewrite runs on the
109
+ resident agent — a distro-side `rg` must exist — and an agent that is out
110
+ drops the same spawn to the one-shot `wsl.exe --exec` handle
111
+ (`lib/search-exec.js`): a transport fallback, never the 9p share.
112
+ - **File tools fail closed.** `wsl-fs.substrate` has one working value,
113
+ `agent`; a profile still carrying the retired `share` is refused at
114
+ construction. When the agent is out these operations fail closed with
115
+ `FS_IO_ERROR` naming the substrate — deliberately, there is no I/O to
116
+ degrade into, and with the share retired no model-facing file path crosses
117
+ 9p.