dsh-plugin-wsl-env 0.8.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,11 +7,36 @@ All notable changes to this project are documented here. The format follows
7
7
  The reasoning and the measurements behind each entry are in the archived
8
8
  engineering record,
9
9
  [docs/archive/engineering-record.zh.md](docs/archive/engineering-record.zh.md)
10
- (Chinese, repository only — it is history, never a second README). Every `§`
11
- reference below points at that record's numbering.
10
+ (Chinese, repository only — it is history, never a second README). A `§`
11
+ reference below points at that record's numbering unless it names another
12
+ document: the 0.1.0 entry's `design-history §` references are to
13
+ [docs/archive/design-history.zh.md](docs/archive/design-history.zh.md), which
14
+ keeps the superseded designs under the section numbers they had while they
15
+ were part of the record.
12
16
 
13
17
  ## [Unreleased]
14
18
 
19
+ ## [0.9.0] - 2026-10-06
20
+
21
+ A usability round over the sandbox failure path: the failure stops being a
22
+ wall the first command walks into, and becomes a message that arrives early
23
+ and says how to fix it.
24
+
25
+ The bwrap refusal now classifies its cause — missing versus
26
+ present-but-failing — and prints the direct install command in the distro's
27
+ own package family (apt/dnf/pacman/zypper) instead of a hardcoded `apt-get`
28
+ line; every consumer composes it from one place. A new `wsl-preflight`
29
+ service runs the same shared probe when a distro session opens and surfaces
30
+ an unusable bwrap as a warning with the full remedy, moving the discovery
31
+ moment ahead of the first failed command — advisory only, never installing.
32
+ `pnpm run diagnose` collects a read-only, paste-ready diagnostic report for
33
+ issues, and the three bare wsl-probe failures (no distro, no `$HOME`, no
34
+ shell) now carry their next steps. The agent probe's parity legs classify
35
+ the nested-interop service timeout (`Wsl/Service/WSAETIMEDOUT`, observed on
36
+ WSL 3.0.1.0) as SKIP with the evidence attached, so an environment failure
37
+ that prevents the comparison reads as what it is instead of a parity
38
+ violation.
39
+
15
40
  ## [0.8.0] - 2026-10-06
16
41
 
17
42
  The toolchain's TypeScript story completes (pnpm, a checkJs gate, and 36 of 36
@@ -31,10 +56,34 @@ host's own floor (`^22.19.0 || >=24.0.0`), so the Node 20 legs are gone, and
31
56
  the per-target lock test drops its unused `withResolvers` gate, a Node 22+ API
32
57
  that hung those legs on every push.
33
58
 
59
+ The unit suite also takes on the integration layer. `test/provider.test.mjs`
60
+ boots the REAL `WslShellExecutor` and `WslFileSystem` with substitutes only at
61
+ the seams that would reach `wsl.exe` — the agent, the substrate, the
62
+ confinement — so the guard chain, the mutation routing, the agent execution
63
+ path with its result decoration, the one-shot fallback and the runner-failure
64
+ remap are measured: `lib/index.js` goes from 60% lines / 9% functions covered
65
+ to 97%/90% when it joins, and measures 97.49% lines / 95.16% functions in the
66
+ released tree. The coverage gate's branch threshold moves with it, 85 to 84 —
67
+ the newly measured layer's uncovered tail is the distro-resolution branches
68
+ (`defaultDistro`'s no-distro throw, `linuxHomePath`'s refusals, the watcher's
69
+ real spawn internals) that need a live `wsl.exe`, and RELEASING.md records
70
+ the bar for raising it again: cover that tail on the Windows CI legs, or move
71
+ the coverage job to `windows-latest`. And the probes join CI: a
72
+ `workflow_dispatch` job — named for the self-hosted Windows runner, though it
73
+ carries the hosted `windows-latest` label — runs five of them: `sandbox`
74
+ unconditionally, since it needs only bubblewrap in the default distro, then
75
+ `mode`, `terminal`, `picker` and `missing-wsl` where the harness app is
76
+ present. The `sandbox` leg fails, not skips, where bubblewrap is absent, so a
77
+ dispatch to a hosted runner is no green no-op. Separately,
78
+ `test/probe/run-all-when-closed.sh`'s suite gains the four probes it was
79
+ missing — `agent`, `watch`, `substrate`, `exec`.
80
+
34
81
  One more theme: the whole repository comes under a TypeScript check gate.
35
82
  `tsc --noEmit` under `strict`/`checkJs` now runs as part of `pnpm test` over
36
- `lib/**`, `test/**` and the probe scripts, with the types taken from the peer
37
- packages' own declarations and the service augmentations loaded through
83
+ `lib/**`, `test/**` and the probe scripts — a scope that narrows to what is
84
+ NOT generated once the migration completes, `lib/**` leaving the gate for the
85
+ build program below — with the types taken from the peer packages' own
86
+ declarations and the service augmentations loaded through
38
87
  `types/dsh-services.d.ts`. The gate's first pass surfaced the seam work this
39
88
  package already carries: the private `spawnSpec` the WSL spawn directory needs
40
89
  (`docs/UPSTREAM-SPAWN-SEAM.md` bridges it) and a synchronous-contract violation
@@ -90,7 +139,9 @@ keeps those names, so nothing downstream changes except that they now come
90
139
  from a checked source. CONTRIBUTING records the per-module checklist this
91
140
  settled into: write `src/<name>.ts`, add the generated JavaScript to the two
92
141
  `exclude` lists, build, and let the suite verify the artifact is
93
- behaviourally identical.
142
+ behaviourally identical — the `exclude` step lasts only while the tree is
143
+ mixed; the finished checklist ends at write, build and test, with no list to
144
+ maintain.
94
145
 
95
146
  Three leaf modules then follow in one pass — `agent-errors` (the two error
96
147
  classes the executor switches on), `probe-cache` (the verdict policy that
@@ -221,7 +272,7 @@ The transitional scaffolding then retires, nothing left for it to do:
221
272
  declaration-only second pass (`tsconfig.dts.json`) existed to give the
222
273
  hand-written JavaScript its `.d.ts`. `pnpm run build` is one `tsc` pass
223
274
  emitting JavaScript and declarations straight into `lib/`, pruning
224
- declarations no source owns; the `checkJs` gate drops its 36-entry exclude
275
+ declarations no source owns; the `checkJs` gate drops its 35-entry exclude
225
276
  list and states what is NOT generated — `test/**` and the ambient service
226
277
  types, with `src/` checked by the build program (`noEmitOnError`); and
227
278
  `lint:build` enforces the closed corpus (every `lib/*.js` has its
@@ -995,6 +1046,17 @@ overwrite guard that stopped at the host.
995
1046
  app's own runtime, both entries import; `test/syntax.mjs` now checks every
996
1047
  `z.<member>` in `lib/` against the pinned peer's surface, which fails on
997
1048
  `z.enum` and passes on `z.union`.
1049
+
1050
+ ## [0.2.0] - 2026-10-03
1051
+
1052
+ One theme: the Remote-WSL parity plan (`docs/PARITY.md`) lands, Phases 0–7 —
1053
+ a resident in-distro agent plus the shell-execution, watch, publication,
1054
+ ports, terminal-routing and bootstrap work that migrates the seams onto it.
1055
+ The release closes with the two agent-path breaks that took down every GUI
1056
+ shell call, both surfacing only through the GUI's wire protocol.
1057
+
1058
+ ### Fixed
1059
+
998
1060
  - **`truncated` missing from the agent path's output streams** — the bash tool's
999
1061
  canonical result copies the field unconditionally (`canonicalBashResult`), so an
1000
1062
  `undefined` failed the wire's lossless-JSON snapshot and EVERY shell tool call
@@ -1077,9 +1139,6 @@ overwrite guard that stopped at the host.
1077
1139
  Probed end-to-end against a real distro: `test/probe/agent.sh` (8 checks, including
1078
1140
  binary-safe payloads, an in-distro timeout kill, and a 2 MB single-line response).
1079
1141
  Unit tests cover the codecs and the lifecycle state machine with a fake transport.
1080
-
1081
- ### Added
1082
-
1083
1142
  - `docs/PARITY.md`: the plan to reach VS Code Remote-WSL-grade experience — a resident
1084
1143
  in-distro agent as the core, with the `fs` and `shell` seams migrated onto it and the
1085
1144
  current 9p/one-shot paths kept as the documented fallback — plus the Phase-0 contract
@@ -1621,9 +1680,9 @@ freshly created files.
1621
1680
  - Three peer declarations this plugin neither imports nor injects:
1622
1681
  `@deepseek-ai/dsh-sandbox`, `@deepseek-ai/dsh-shell` and
1623
1682
  `@deepseek-ai/dsh-tools`. They were required by the abandoned tool-renaming
1624
- designs (archive §15/§18), which could only stub the sandbox symbols locally
1625
- (archive §15.5). The remaining set is checkable in one command —
1626
- `grep -rho 'from "@deepseek-ai/[^"]*"' lib/ | sort -u` — plus
1683
+ designs (design-history §15/§18), which could only stub the sandbox symbols
1684
+ locally (design-history §15.5). The remaining set is checkable in one
1685
+ command — `grep -rho 'from "@deepseek-ai/[^"]*"' lib/ | sort -u` — plus
1627
1686
  `@deepseek-ai/dsh-subprocess` (the service `static inject` requires) and
1628
1687
  `@deepseek-ai/cordis`, which every DSH plugin declares.
1629
1688
  (`@deepseek-ai/dsh-sandbox` came back in this same release: the distro-side
package/CONTRIBUTING.md CHANGED
@@ -14,14 +14,22 @@ the conventions this repository uses.
14
14
 
15
15
  ## Toolchain
16
16
 
17
- The runtime package ships an empty `dependencies` object on purpose: it is
17
+ The runtime package ships no `dependencies` field at all, on purpose: it is
18
18
  loaded into the DSH process and everything it needs arrives as a peer package.
19
19
  Running the plugin from a checkout has no install step.
20
20
 
21
- Developing and running the gates do have dependencies. The 0.7.x tests import
22
- pinned `@deepseek-ai/*` packages — the composition and boot tests run the
23
- loader's real patch algorithm — so a fresh checkout needs one `pnpm install`
24
- before the gates pass.
21
+ Developing and running the gates do have dependencies. The tests import pinned
22
+ `@deepseek-ai/*` packages — the composition test runs the loader's real patch
23
+ algorithm, and the boot test starts a row through the real Loader — so a fresh
24
+ checkout needs one `pnpm install` before the gates pass.
25
+
26
+ Those pins are deliberate, and they move with the harness rather than on their
27
+ own: the `@deepseek-ai/dsh-*` peers sit at `0.2.0-rc.2`, `@deepseek-ai/cordis`
28
+ at `~4.0.4` and `@deepseek-ai/schemastery` at `~3.18.4`. The plugin subclasses
29
+ peer classes and mirrors peer protocol, so raising one pin alone is how the
30
+ seams drift: bump them in the commit that matches a harness release, re-run the
31
+ comparison `docs/PEER-PARITY.md` describes, and let the gate chain and the
32
+ probes confirm the result.
25
33
 
26
34
  pnpm runs the scripts in this repository, pinned through the `packageManager`
27
35
  field (corepack resolves it). Treat `pnpm run <script>` as the reference
@@ -40,24 +48,24 @@ Exactly three dependencies run install scripts (`@deepseek-ai/dsh-subprocess-loc
40
48
 
41
49
  ## The type gate
42
50
 
43
- `lint:types` runs `tsc --noEmit` under `strict` with `checkJs`: the JavaScript
44
- in `lib/` is fully type-checked, and the types come from the peer packages'
45
- own `.d.ts` — nothing is invented twice. `types/dsh-services.d.ts` pulls in the
46
- `declare module '@deepseek-ai/cordis'` augmentations the service packages ship,
47
- so `ctx.fs`, `ctx.shell`, `ctx.sandboxPolicy` and friends are typed; it declares
48
- nothing itself and ships nowhere.
49
-
50
- The gate's scope is what is NOT generated: `test/**` (the probe scripts
51
- included) and the ambient service types the build shares. The sources under
52
- `src/` are checked by the build program itself (`strict`, `noEmitOnError`),
53
- which is strictly stronger than checking their emitted JavaScript. The shipped
54
- artifact was checked first because it is where protocol drift with the pinned
55
- peers hurts; the tests and probes came next, and the errors they surfaced were
56
- real — a test that pinned a raw number into an argv, a couple of hand-written
57
- JSDoc contracts wider than the code (`wslErrorCode`, `gitSpawnRewrite`) and
58
- narrower than their seams, and one accepted-but-never-enforced byte-window cap
59
- in the distro-routed file service, found because a fake hands the confinement
60
- seam an `"full"` enforcement the core itself never reports.
51
+ `lint:types` runs `tsc --noEmit` under `strict` with `checkJs` over what is NOT
52
+ generated — `test/**` and the ambient types — and the types come from the peer
53
+ packages' own `.d.ts`: nothing is invented twice. `types/dsh-services.d.ts`
54
+ pulls in the `declare module '@deepseek-ai/cordis'` augmentations the service
55
+ packages ship, so `ctx.fs`, `ctx.shell`, `ctx.sandboxPolicy` and friends are
56
+ typed; it declares nothing itself and ships nowhere.
57
+
58
+ The probe scripts under `test/probe/` are inside the gate as well. The sources
59
+ under `src/` are checked by the build program itself (`strict`,
60
+ `noEmitOnError`), which is strictly stronger than checking their emitted
61
+ JavaScript. The shipped artifact was checked first because it is where protocol
62
+ drift with the pinned peers hurts; the tests and probes came next, and the
63
+ errors they surfaced were real — a test that pinned a raw number into an argv,
64
+ a couple of hand-written JSDoc contracts wider than the code (`wslErrorCode`,
65
+ `gitSpawnRewrite`) and narrower than their seams, and one
66
+ accepted-but-never-enforced byte-window cap in the distro-routed file service,
67
+ found because a fake hands the confinement seam an `"full"` enforcement the core
68
+ itself never reports.
61
69
 
62
70
  Three patterns keep the check honest:
63
71
 
@@ -69,9 +77,12 @@ Three patterns keep the check honest:
69
77
  at the injection point — the cast states which contract the double stands in
70
78
  for, instead of silencing the mismatch with `any`.
71
79
  - `@ts-expect-error` is allowed exactly when the suppressed error names a real
72
- upstream seam gap, carries the reason inline, and the gap is written up in
73
- `docs/UPSTREAM-*.md`. It is a published finding with a tracking doc, not a
74
- silencer.
80
+ upstream seam gap, carries the reason inline, and the gap is written up — in a
81
+ `docs/UPSTREAM-*.md` tracking doc when the fix is an upstream ask (the
82
+ `LocalBashExecutor.spawnSpec` seam has one), or in the source comment itself
83
+ when the honest fix needs a protocol decision this package owns (the exit-4
84
+ relay's unknown entry kind is the other site, deliberately left visible). It
85
+ is a published finding, not a silencer.
75
86
 
76
87
  ## The build
77
88
 
@@ -119,15 +130,25 @@ Rules that keep this honest:
119
130
  2. `pnpm run build`, then `pnpm test`. There is no exclude list to maintain and
120
131
  no registration step: the build and `lint:build` derive everything from
121
132
  `src/`, and `lib/` is generated output — never edit it by hand.
133
+ 3. If the module gets a row of its own on the Plugins page, add its display
134
+ metadata as `locale/<name>/en.json` and `locale/<name>/zh.json` — a
135
+ `meta.title` / `meta.description` pair each — and expose both through
136
+ `exports` (`./<name>/locale/en.json`, `./<name>/locale/zh.json`), which is
137
+ what that page reads before falling back to the package `description`. The
138
+ bundle's own pair is `locale/en.json` / `locale/zh.json`, and `subprocess`,
139
+ `picker`, `shell-env` and `auto-preset` carry theirs; `files` already ships
140
+ `locale/**/*.json`, and `lint:style` fails an `exports` target that does not
141
+ exist.
122
142
 
123
143
  ## The development loop
124
144
 
125
145
  1. Clone the repository inside the distro. Most contributors develop there.
126
146
  2. Make the change.
127
147
  3. Run the gates below.
128
- 4. Restart the DSH app before testing by hand, if you changed anything under
129
- `lib/`. A running process caches ES modules, so it keeps the old code
130
- otherwise.
148
+ 4. If you changed anything under `lib/`, run `pnpm run sync:windows` first and
149
+ then restart the DSH app. The app loads the Windows mirror (see below), so a
150
+ restart without the sync just reloads the old code; a running process also
151
+ caches ES modules.
131
152
 
132
153
  ### The runtime mirror
133
154
 
@@ -151,26 +172,28 @@ terminal if you would rather not see the prompt.
151
172
 
152
173
  | Command | Covers | Needs a harness |
153
174
  |---|---|---|
154
- | `pnpm test` | style checks, syntax pass, type check, and unit tests | no |
175
+ | `pnpm test` | style checks, syntax pass, build consistency, type check, and unit tests | no |
155
176
  | `pnpm run test:coverage` | unit tests with coverage | no |
156
177
  | `pnpm run lint:style` | style and lint rules | no |
157
- | `pnpm run lint:types` | the TypeScript check over `lib/` (`tsc --noEmit`) | no |
178
+ | `pnpm run lint:build` | the build-freshness check: every source has its artifacts and the committed bytes match a real build | no |
179
+ | `pnpm run lint:types` | the TypeScript check over `test/**` and the ambient types (`tsc --noEmit`) | no |
158
180
  | `pnpm run probe` | filesystem behaviour against a real distro | yes |
159
181
  | `pnpm run probe:sandbox` | what bubblewrap confines and what it does not | no |
160
182
  | `pnpm run probe:sandbox-shell` | the confined executor through a real boot | yes |
161
183
  | `pnpm run probe:terminal` | the terminal provider | yes |
162
- | `pnpm run probe:substrate` | the agent filesystem substrate over a real `wsl.exe` transport | yes; inside the distro |
163
- | `pnpm run probe:watch` | the in-distro watcher over a real directory | yes; inside the distro |
164
- | `pnpm run probe:agent` | the resident-vs-one-shot fallback parity legs | yes; inside the distro |
165
- | `pnpm run probe:exec` | the agent-backed execution handle: timeout, kill, cwd failure | yes; inside the distro |
184
+ | `pnpm run probe:substrate` | the agent filesystem substrate over a real `wsl.exe` transport | no; inside the distro |
185
+ | `pnpm run probe:watch` | the in-distro watcher over a real directory | no; inside the distro |
186
+ | `pnpm run probe:agent` | the resident-vs-one-shot fallback parity legs | no; inside the distro |
187
+ | `pnpm run probe:exec` | the agent-backed execution handle: timeout, kill, cwd failure | no; inside the distro |
166
188
  | `pnpm run probe:missing-wsl` | how a `wslPath` that cannot start is reported | yes |
167
189
  | `pnpm run probe:picker` | the picker's root level, its refusals and its cap | yes |
168
190
  | `pnpm run probe:mode` | which POSIX-mode facts survive the share | no; Windows Node |
169
191
  | `pnpm run probe:sandbox-off` | the documented opt-out on both providers | yes |
170
192
 
171
193
  Every script above is wired in `package.json`. `pnpm test` runs `lint:style`,
172
- `test:syntax`, `lint:types` and `test:unit` in that order. `prepublishOnly` runs `pnpm test` again
173
- at publish time, so a broken gate stops a release before the upload.
194
+ `test:syntax`, `lint:build`, `lint:types` and `test:unit` in that order.
195
+ `prepublishOnly` runs `pnpm test` again at publish time, so a broken gate stops a
196
+ release before the upload.
174
197
 
175
198
  CI runs `pnpm test` on Node 22 and 24, on both `ubuntu-latest` and
176
199
  `windows-latest`, and `pnpm run test:coverage` on Node 24. The Windows runners are
@@ -181,6 +204,19 @@ The probes marked "yes" need Windows, WSL2, a mounted harness profile and a link
181
204
  checkout, which a hosted runner does not have. Run them on your own machine and
182
205
  paste the output into the pull request.
183
206
 
207
+ The rows marked "no; inside the distro" (`substrate`, `watch`, `agent`, `exec`)
208
+ still need Windows and WSL2, but no harness boot and no profile: run them from a
209
+ distro terminal with Node available, and they reach `wsl.exe` through interop.
210
+
211
+ Two conveniences for the real-machine set. `test/probe/run-all-when-closed.sh
212
+ --include-fs` runs all twelve probes once the app has exited. And `ci.yml` has a
213
+ `probes` job you dispatch by hand: `sandbox` runs whenever WSL is present, then
214
+ `mode`, `terminal`, `picker` and `missing-wsl` where the harness app is
215
+ installed. It is named for a self-hosted Windows runner although its `runs-on`
216
+ carries the hosted `windows-latest` label, and it is not a green no-op on a
217
+ hosted runner — the `sandbox` leg fails, not skips, where the default distro has
218
+ no bubblewrap.
219
+
184
220
  ## Adding a test
185
221
 
186
222
  Choose the layer by what the test needs.
@@ -190,44 +226,70 @@ Choose the layer by what the test needs.
190
226
  | Unit test | `test/*.test.mjs` | pure logic: path translation, listing, shell argument building, terminal helpers |
191
227
  | Probe | `test/probe/` | behaviour that needs a real distro, a real profile, or bubblewrap |
192
228
 
193
- Unit tests run under `node --test`. A test that imports only this repository goes in
194
- both the `test:unit` and the `test:coverage` lists in `package.json`, which name
195
- their files explicitly. A test that needs a DSH peer — `test/sandbox.test.mjs`
196
- imports `lib/sandbox.js`, which imports `@deepseek-ai/dsh-sandbox` — goes in
197
- `test:unit` only and must skip itself when the peer is missing, because CI installs
198
- nothing: try the import, print a `SKIP` line, exit 0.
199
-
200
- Probes are shell scripts that mount a throwaway profile, plus the small plugin
201
- files they load. Those plugin files are the `*-probe.mjs` and `*-probe.yml` files
202
- in the same directory. The setup steps for the throwaway profile are in the header
203
- of `test/probe/run.sh`. `terminal.sh` and `sandbox-shell.sh` reuse it.
229
+ Unit tests run under `node --test`. The `test:unit` and the `test:coverage` lists
230
+ in `package.json` name their files explicitly; a test that only touches this
231
+ repository belongs in both. Two lags in those lists today are hygiene to fix in
232
+ passing, not a pattern to copy: each names one file twice
233
+ (`test/fsio-text.test.mjs` in `test:unit`, `test/fs.boot.test.mjs` in
234
+ `test:coverage`), and `test/fs-routing.test.mjs` is in neither
235
+ (`docs/REQUIREMENTS.zh.md` tracks that as OI-7).
236
+
237
+ A test that imports a pinned `@deepseek-ai/*` package goes in the same lists and
238
+ needs no guarding — CI runs `pnpm install --frozen-lockfile` over the locked tree
239
+ before `pnpm test`. Five files import such packages at runtime today —
240
+ `test/provider.test.mjs` is what covers the assembled service rows, and
241
+ `test/composition.test.mjs` runs the loader's real patch algorithm — and a sixth
242
+ (`test/terminal-activity.test.mjs`) reaches a peer only through a JSDoc type
243
+ import, which `checkJs` still resolves.
244
+
245
+ Where a peer-free seam exists, prefer it. `test/sandbox.test.mjs` runs against
246
+ `lib/sandbox-core.js`, with a stand-in for the peer's `SandboxUnavailableError`
247
+ contract, which leaves only the one-line peer binding in `lib/sandbox.js`. That
248
+ line is covered by the harness probes, which boot the plugin and so reach
249
+ `WslSandbox` through `lib/index.js` — not by `probe:sandbox`, which reads only
250
+ the argument builder out of `lib/bwrap.js`. A test that cannot run everywhere
251
+ must skip itself — print a `SKIP` line and exit 0 — the way that file does on
252
+ `win32`, where its fake `wsl.exe` (a POSIX script) cannot execute.
253
+
254
+ Probes are shell scripts, plus the small plugin files they load: the `*-probe.mjs`
255
+ and `*-probe.yml` files in the same directory. Two shapes. The harness probes
256
+ (`run.sh`, `terminal.sh`, `sandbox-shell.sh`, `picker.sh`, `missing-wsl.sh`,
257
+ `sandbox-off.sh`) boot a throwaway profile whose setup steps are in the header of
258
+ `test/probe/run.sh`; the five others reuse the profile `run.sh` creates rather
259
+ than making their own. The distro probes (`substrate.sh`, `watch.sh`, `agent.sh`,
260
+ `exec.sh`) boot nothing: they run from a distro terminal and drive the resident
261
+ agent through the `wsl.exe` interop transport. `sandbox.sh` and `mode.sh` sit
262
+ outside both groups — no harness boot, no profile.
204
263
 
205
264
  Keep probe output idempotent. Several probes delete their scratch files at the
206
265
  start, because a leftover file from an earlier run changes the result.
207
266
 
208
267
  ## What has been verified
209
268
 
210
- `pnpm test` covers the pure modules and runs a `--check` pass over every shipped
211
- module. It cannot import the service modules, because those need DSH peer packages
212
- that a bare checkout does not have. Only booting the harness closes that gap, which
213
- is why the probes exist; the archived record's §15.4 documents the five rounds of
214
- misdiagnosis the gap once caused.
269
+ `pnpm test` runs the whole unit suite once the one `pnpm install` above has
270
+ fetched the pinned packages: the files that import them statically exercise the
271
+ services through the loader and their declared types, and `test:syntax` keeps a
272
+ `node --check` pass over every shipped module. What no test on a Linux leg can
273
+ reach is the Windows-plus-WSL2 topology itself: a real `wsl.exe`, a real distro,
274
+ a real harness boot. That is the gap the probes close; the archived record's
275
+ §15.4 documents the five rounds of misdiagnosis it once caused.
215
276
 
216
277
  `test/probe/sandbox.sh` needs no harness: it uses the same arguments
217
278
  `lib/sandbox.js` builds and asserts what bubblewrap does and does not confine,
218
279
  recording the interop escape as `INFO` because a Linux sandbox cannot govern a
219
- Windows process. The other probes boot a throwaway profile bound to the distro:
280
+ Windows process. The harness probes boot a throwaway profile bound to the distro:
220
281
 
221
282
  - the filesystem probe asserts the write path and the write checks — a write outside
222
283
  the policy root and a write in `read-only` mode both return `FS_SANDBOX_DENIED`,
223
- while `danger-full-access` is not checked;
284
+ while `danger-full-access` is the approved escalation and its write is asserted
285
+ to land;
224
286
  - the shell probe drives all three modes through `ctx.shell` and checks the refusal
225
287
  classification the tool layer returns;
226
288
  - the terminal probe asserts the distro, the starting directory, and the `DSH_*`
227
289
  variables forwarded through `WSLENV`.
228
290
 
229
- The setup steps for the throwaway profile are in the header of `test/probe/run.sh`;
230
- `terminal.sh` and `sandbox-shell.sh` reuse it.
291
+ The setup steps for that throwaway profile are in the header of
292
+ `test/probe/run.sh`; the harness probes share the one profile it creates.
231
293
 
232
294
  On Windows 11 + WSL2 (Ubuntu 26.04) the following has been verified:
233
295
 
@@ -242,11 +304,12 @@ On Windows 11 + WSL2 (Ubuntu 26.04) the following has been verified:
242
304
  outside the session workspace is refused inside the distro and the following
243
305
  wider-permission request succeeds.
244
306
 
245
- Counts: 57 unit assertions on a bare checkout, plus 5 in `test/sandbox.test.mjs` which
246
- need the `dsh-sandbox` peer and skip themselves without it; 19 filesystem-probe
247
- assertions; 15 shell-probe checks; 9 picker checks; 6 missing-executable checks; 5 mode
248
- checks; 5 opt-out checks and 10 sandbox expectations plus the recorded escape; and 3
249
- terminal assertions.
307
+ Counts: 28 unit test files under `test/` — 27 of them registered in the
308
+ `test:unit` list, see the OI-7 note above — which need the pinned packages
309
+ installed, and over 1000 `assert` call sites across them; 19 filesystem-probe
310
+ assertions; 15 shell-probe checks; 9 picker checks; 6 missing-executable checks;
311
+ 5 mode checks; 5 opt-out checks and 10 sandbox expectations plus the recorded
312
+ escape; and 3 terminal assertions.
250
313
 
251
314
  ## Commit and pull request conventions
252
315
 
package/README.md CHANGED
@@ -26,25 +26,31 @@ dsh --profile wsl # 4. run
26
26
 
27
27
  Step 3 should print a layer named `# == dsh-plugin-wsl-env`, and `- id: terminal-controller` should now carry `shell: { path: wsl.exe, name: WSL }`. The package is a DSH bundle, so step 2 applies [`cordis.patch.yml`](cordis.patch.yml) as a configuration layer; nothing is merged by hand.
28
28
 
29
- Then open a folder under `\\wsl.localhost\<distro>\...` in the GUI. The picker lists every installed distro at its root level, and **New terminal** opens a shell in the distro.
29
+ **Before the first command, give the distro bubblewrap.** Every command runs confined by [bubblewrap](#sandbox), which most distros do not preinstall, and without it every command fails closed — by design, never unconfined:
30
+
31
+ ```powershell
32
+ wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap # Debian/Ubuntu
33
+ ```
30
34
 
31
- You also need **bubblewrap inside the distro**: run `pnpm run bootstrap -- <distro> --install` (drop `--install` for a read-only check), or paste `wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap`. Without it every command fails closed, see [Sandbox](#sandbox).
35
+ From a checkout of this plugin, `pnpm run bootstrap -- <distro>` checks all four tools the plugin uses (bubblewrap, ripgrep, git, inotify-tools) and prints the install command for the distro's own package family; add `--install` to run it. A skipped step announces itself: when a distro session opens, the plugin probes bubblewrap and warns with the exact remedy — the first command never has to be the discovery moment.
36
+
37
+ Then open a folder under `\\wsl.localhost\<distro>\...` in the GUI. The picker lists every installed distro at its root level, and **New terminal** opens a shell in the distro.
32
38
 
33
39
  Uninstall: `dsh plugin --profile wsl remove dsh-plugin-wsl-env`. Upgrade: run the same `add` command again.
34
40
 
35
- > **Changed anything under `lib/`? Restart the app.** A running process caches ES modules and keeps the old code otherwise.
41
+ > **Changed anything under `lib/`? Sync the mirror, then restart the app.** The app loads the Windows-side runtime mirror, so run `pnpm run sync:windows` first — a restart without the sync just reloads the old code; a running process also caches ES modules. See [Development](#development).
36
42
 
37
43
  ## Using it
38
44
 
39
45
  Open `\\wsl.localhost\ubuntu\home\you\project` as the workspace, then ask the model "what kernel am I on, and what is in `/etc/os-release`?". It runs `uname -r` and reads that file inside the distro. Nothing goes through `/mnt/c`, and no files are copied.
40
46
 
41
47
  - **A distro folder gets the distro environment automatically.** The `wsl` preset is bound while the session is created, so the first tool call is already correct.
42
- - **Commands** run as `wsl.exe -d <distro> --cd <linux dir> --exec <your login shell> -lc <command>`, so your `PATH`, `nvm`, `cargo`, `pyenv` and rc files apply. The shell is not hardcoded to bash.
48
+ - **Commands** run inside the distro in your login shell, so your `PATH`, `nvm`, `cargo`, `pyenv` and rc files apply; the shell is not hardcoded to bash. The default is the resident in-distro agent — one long-lived process per distro; while it is out (or with `agent: false`) the same command falls back to one `wsl.exe -d <distro> --cd <linux dir> --exec <your login shell> -lc <command>` process.
43
49
  - **Files are the distro's real files.** `/home/you/x` and `\\wsl.localhost\ubuntu\home\you\x` are the same file, and `/mnt/c/...` reaches the Windows disk.
44
50
  - **The terminal** (right sidebar, then *New terminal*) opens a shell inside the distro, in the session's folder.
45
51
  - **Port visibility.** The model sees which ports have a listener inside the distro (`DSH_WSL_PORTS`, refreshed about every 10 s), so it can hand you the exact URL of a dev server it just started; WSL2's localhost forwarding makes it reachable from Windows directly.
46
- - **The model sees its shell environment.** The plugin registers `DSH_WSL_DISTRO`, `DSH_WSL_SHELL` and `DSH_WSL_HOME` in the managed `DSH_*` namespace.
47
- - **Permissions work as on a Linux host.** The Permissions selector switches between `read-only`, `workspace-write` (the default) and `danger-full-access`. A refused command or write comes back with an offer to run it with wider permissions; if you approve, that one call runs without the sandbox.
52
+ - **The model sees its shell environment.** The plugin registers `DSH_WSL_DISTRO`, `DSH_WSL_SHELL`, `DSH_WSL_HOME` and the port snapshot `DSH_WSL_PORTS` in the managed `DSH_*` namespace.
53
+ - **Permissions work as on a Linux host.** The Permissions selector switches between `read-only`, `workspace-write` (the default) and `danger-full-access`. A refused command or write comes back with an offer to retry it once with the narrowest wider mode that suffices; if you approve, that one call runs in the approved mode — from `read-only` that is usually `workspace-write`, still inside the sandbox, and only an escalation to `danger-full-access` runs without one.
48
54
 
49
55
  ## Configure
50
56
 
@@ -61,15 +67,15 @@ Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys w
61
67
  | `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory |
62
68
  | `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
63
69
 
70
+ `wsl-shell` and `wsl-fs` are the two rows a by-id patch **cannot** reach: they are plugins of the `wsl` agent preset, nested inside the `preset-wsl` row's `config.plugins`, so `- id: wsl-shell` in your layer changes nothing — the loader only warns `patch: entry "wsl-shell" not found` and the shipped value stays in force. Override the `preset-wsl` row as a whole instead; [docs/CONFIGURATION.md](docs/CONFIGURATION.md#overriding-the-rows-inside-preset-wsl) has the recipe.
71
+
64
72
  [`cordis.patch.yml`](cordis.patch.yml) is the commented reference for every shipped value. [docs/CONFIGURATION.md](docs/CONFIGURATION.md) lists the rest, including `shell`, `loginShell`, `cwd`, `timeoutMs`, `preferredDistro` and `maxEntries`; [examples/profile.cordis.patch.yml](examples/profile.cordis.patch.yml) is a machine-local layer to copy from.
65
73
 
66
74
  ## Recipes
67
75
 
68
76
  - **Git credential sharing**: let git inside the distro use the Windows-side Git
69
77
  Credential Manager — `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
70
- (adjust the path to your Windows Git install; WSL2's localhost forwarding is
71
- platform behaviour, so ports a dev server listens on inside the distro are
72
- reachable from Windows directly).
78
+ (adjust the path to your Windows Git install).
73
79
  - **Paths and performance**: the model sees and operates on Linux paths
74
80
  (`/home/...`) on the distro's own ext4. `/mnt/c` reaches the Windows disk over
75
81
  9p — noticeably slow for many small files; keep heavy-IO projects on the
@@ -90,17 +96,20 @@ composition (app level, one per process)
90
96
  │ WSL-folder session → the distro shell, in the session's Linux directory
91
97
  │ Windows-folder session → powershell.exe, in the session's Windows directory
92
98
  ├─ directory-picker-wsl installed distros listed beside the Windows home
99
+ ├─ workspace-files-wsl the GUI file tree and previews of a distro workspace, served from inside it
100
+ ├─ fs-routing the root filesystem, routed by coordinate
101
+ │ distro UNC → the resident agent; a drive path → the host backend
93
102
  ├─ wsl-shell-env the DSH_WSL_DISTRO / _SHELL / _HOME / _PORTS facts the model sees
94
103
  └─ auto-preset binds the wsl preset when a session opens a distro folder
95
104
 
96
105
  preset-wsl (the wsl agent preset; its services run in isolate realms)
97
106
  ├─ wsl-shell ctx.shell — wsl.exe --exec <login shell>, confined by bubblewrap inside the distro
98
- └─ wsl-fs ctx.fs — real distro files; agent substrate on ext4, or the 9p share
107
+ └─ wsl-fs ctx.fs — real distro files on the resident agent substrate (ext4)
99
108
  ```
100
109
 
101
- **Why two levels.** `wsl-shell` and `wsl-fs` live inside the `wsl` agent preset, which `auto-preset` binds whenever a session's workspace is inside the distro: a Windows-folder session keeps the stock providers, a distro session gets the WSL ones — the environment is a property of the session, not of the process. The terminal controller is the exception: it resolves its execution world through the root context, which never sees a preset's isolate realms, so `subprocess-wsl` sits at the composition level instead.
110
+ **Why two levels.** `wsl-shell` and `wsl-fs` live inside the `wsl` agent preset, which `auto-preset` binds whenever a session's workspace is inside the distro: a Windows-folder session keeps the stock providers, a distro session gets the WSL ones — the environment is a property of the session, not of the process. The terminal controller is the exception: it resolves its execution world through the root context, which never sees a preset's isolate realms, so `subprocess-wsl` sits at the composition level instead. `workspace-files-wsl` and `fs-routing` are composition-level for the mirror-image reason: the GUI file tree and the root `ctx.fs` are session-less, so they cannot be served from a preset. They replace the shipped `workspace-files` and root `fs-sandbox` rows outright; [docs/root-fs-routing.md](docs/root-fs-routing.md) is the design of the root-plane routing.
102
111
 
103
- **Commands and files.** A command runs as `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>` inside a distro-side bubblewrap profile assembled with the same arguments as DSH's own Linux runner, so confinement semantics and error messages match a Linux host. The file tools read and write real distro files on the resident in-distro agent — reads, writes and identities run on ext4 with native symlinks and mode bits — and the file-search spawn is rewritten the same way: a search over a distro workspace runs the distro's own `rg` (`wsl.exe --exec`), never the Windows binary over the 9p share. Writes are checked against the same policy the command sandbox enforces.
112
+ **Commands and files.** By default a command runs over the resident in-distro agent — one long-lived process per distro, which carries the cwd as part of the request instead of a per-command `--cd`. What it runs is the same `<login shell> -lc <cmd>` inside a distro-side bubblewrap profile assembled with the same arguments as DSH's own Linux runner, so confinement semantics and error messages match a Linux host; while the agent is out, or with `agent: false`, the command falls back to one `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>` process. The file tools read and write real distro files on the resident in-distro agent — reads, writes and identities run on ext4 with native symlinks and mode bits — and the file-search spawn is rewritten the same way: a search over a distro workspace runs the distro's own `rg` (`wsl.exe --exec`), never the Windows binary over the 9p share. Writes are checked against the same policy the command sandbox enforces.
104
113
 
105
114
  ## Sandbox
106
115
 
@@ -112,9 +121,9 @@ Commands are confined by `bubblewrap` inside the distro, and file writes are che
112
121
  | `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
113
122
  | `danger-full-access` | no sandbox; used for an approved wider-permission request |
114
123
 
115
- **`bubblewrap` is required, and it fails closed.** Without it every confined command reports `SANDBOX_UNAVAILABLE` instead of running unconfined. Set `sandbox: false` on either provider to opt out; the tool layer then tells the model these operations have no sandbox.
124
+ **`bubblewrap` is required, and it fails closed.** Without it every confined command reports `SANDBOX_UNAVAILABLE` instead of running unconfined. To opt out, set `sandbox: false` on the row that owns the operation — `wsl-shell` for commands, `wsl-fs` for the write fence, and the top-level `fs-routing` row for its own copy. Note that the two preset rows are reached through `preset-wsl`, not by id (see [Configure](#configure)). The tool layer then tells the model these operations have no sandbox.
116
125
 
117
- **The reported enforcement is `partial`, not `full`.** A process inside the distro can still run a Windows program through WSL interop, for example `/mnt/c/.../*.exe`, and bubblewrap does not govern it. `pnpm run probe:sandbox` demonstrates the boundary on your machine.
126
+ **The reported enforcement is `partial`, not `full`.** A process inside the distro can still run a Windows program through WSL interop, for example `/mnt/c/.../*.exe`, and bubblewrap does not govern it. `pnpm run probe:sandbox` demonstrates the boundary on your machine. `maskWindowsDrive: true` on the `wsl-shell`, `wsl-fs` and `fs-routing` rows narrows it — the confined profile shadows `/mnt` with an empty tmpfs, so the drive's files cannot be read or exfiltrated and its executables cannot be launched — but does not close it: a command can still write an executable into the workspace and run it, so enforcement stays `partial`. The complete closure is distro-level, `[interop] enabled=false` in `wsl.conf` (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md)).
118
127
 
119
128
  See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [docs/LIMITATIONS.md](docs/LIMITATIONS.md) for everything the plugin does not do.
120
129
 
@@ -122,12 +131,12 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [do
122
131
 
123
132
  | Symptom | Cause | Fix |
124
133
  |---|---|---|
125
- | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is not installed in the distro | run `pnpm run bootstrap -- <distro> --install`, or set `sandbox: false` on both providers |
134
+ | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is missing from the distro, or present but unusable — the error text distinguishes the two and carries the matching remedy | follow the remedy in the error, or set `sandbox: false` on `wsl-shell` (commands) and `wsl-fs` (writes) — see [Configure](#configure) for how to reach those rows |
126
135
  | a command or write is refused outside the session folder | expected behaviour of `workspace-write` | accept the wider-permission offer, or open a session on the folder you need |
127
136
  | writes are refused even inside the workspace | the session is in `read-only` mode | switch the Permissions selector |
128
- | `dsh plugin add` warns that no layer was activated | the dependency was already installed, so `add` had nothing to record | run `dsh plugin --profile wsl remove dsh-plugin-wsl-env`, then add it again |
137
+ | `dsh plugin add` prints no confirmation that a layer was added | the dependency is already installed and the bundle list is unchanged, so `add` reconciles to the same manifest without reporting anything — that is the success path, not a failure | nothing to do; the layer is already in place, and `dsh --profile wsl --dump-config` still shows `# == dsh-plugin-wsl-env` |
129
138
  | the terminal still opens `cmd.exe` | the `terminal-controller` row from the layer did not apply | check that `dsh --profile wsl --dump-config` shows `shell: { path: wsl.exe, name: WSL }` |
130
- | changes to `lib/` have no effect | ES module cache | restart the app |
139
+ | changes to `lib/` have no effect | the app loads the Windows-side runtime mirror, and a running process also caches ES modules | run `pnpm run sync:windows` to bring the mirror up to date, then restart the app |
131
140
  | `link:\\wsl.localhost\...` leaves a broken symlink | pnpm cannot link a UNC path | link a Windows path instead; developing inside the distro needs the runtime mirror, see [Development](#development) |
132
141
  | `glob` and `grep` are slow | a distro search without rg inside the distro falls back to rg's own "command not found"; a Windows-folder search is native and unaffected | run `pnpm run bootstrap -- <distro> --install` (installs ripgrep); distro searches always run the distro's rg — they never walk the 9p share |
133
142
  | the terminal reports `unknown` activity | only while the resident agent is out — distro terminals are observed from inside the distro (a `DSH_TERMINAL_ID` marker scanned in `/proc`: a shell alone is `idle`, a shell running anything is `busy`) | check the distro is running; idle terminals are reclaimed automatically after the controller's unattended timeout (2 h by default), and `terminalIdleReclaim: false` restores the close-by-hand posture |
@@ -136,8 +145,12 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [do
136
145
  ## Development
137
146
 
138
147
  ```bash
139
- pnpm test # style and packaging checks, syntax check, unit tests
148
+ pnpm run build # compile src/ into lib/ (the artifact is committed; rebuild after editing src/)
149
+ pnpm test # style checks, syntax pass, build consistency, type check, and unit tests
150
+ pnpm run sync:windows # mirror the checkout onto the Windows-side copy the app loads (after any lib/ change, then restart the app)
140
151
  pnpm run test:coverage # the unit tests with coverage thresholds (Node 22.8+)
152
+ pnpm run diagnose # read-only diagnostic report for a bug issue: versions, tools, bwrap probe
153
+ pnpm run bootstrap -- <distro> # check the four distro tools; add --install to install what is missing
141
154
  pnpm run probe:sandbox # measure inside the distro what bubblewrap does and does not confine
142
155
  pnpm run probe # filesystem probe against a real distro (Windows + WSL only)
143
156
  pnpm run probe:sandbox-shell # boot a real harness and drive the confined executor
@@ -148,13 +161,15 @@ pnpm run probe:agent # the resident-vs-one-shot fallback parity legs (f
148
161
  pnpm run probe:exec # the agent-backed execution handle: timeout, kill, cwd failure (from inside the distro)
149
162
  pnpm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
150
163
  pnpm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
151
- pnpm run probe:mode # which POSIX-mode facts survive the share (Windows node only)
164
+ pnpm run probe:mode # which POSIX-mode facts survive the share (needs a Windows node and an installed harness; opens no profile)
152
165
  pnpm run probe:sandbox-off # prove sandbox: false unconfines both providers (Windows + WSL only)
153
166
  ```
154
167
 
155
- One `pnpm install` reproduces the pinned development dependencies before the first run: the 0.7.x tests import pinned `@deepseek-ai/*` packages (the composition and boot tests exercise the loader's real patch algorithm), while the runtime package itself still ships zero dependencies. pnpm is resolved through the `packageManager` field, so any corepack-enabled Node works. CI runs `pnpm test` on Node 22 and 24, on Linux and Windows, and `pnpm run test:coverage` on Node 24; `engines` matches the harness host's own floor (`^22.19.0 || >=24.0.0`).
168
+ The app loads the Windows-side runtime mirror, not the repository checkout: after changing anything under `lib/`, run `pnpm run sync:windows` before restarting the app — otherwise the restart just reloads the old code. `pnpm run build` regenerates `lib/` from `src/`; the artifact is committed, and the build-freshness check inside `pnpm test` verifies it.
169
+
170
+ One `pnpm install` reproduces the pinned development dependencies before the first run: the tests import pinned `@deepseek-ai/*` packages (the composition and boot tests exercise the loader's real patch algorithm), while the runtime package itself still ships zero dependencies. pnpm is resolved through the `packageManager` field, so any corepack-enabled Node works. CI runs `pnpm test` on Node 22 and 24, on Linux and Windows, and `pnpm run test:coverage` on Node 24; `engines` matches the harness host's own floor (`^22.19.0 || >=24.0.0`).
156
171
 
157
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) has the file layout, the mounting, and the sandbox design. [CONTRIBUTING.md](CONTRIBUTING.md) has the development loop, the full gate list, and what has been verified.
172
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) has the file layout, the mounting, and the sandbox design. [CONTRIBUTING.md](CONTRIBUTING.md) has the development loop, the runtime mirror, the full gate list, and what has been verified.
158
173
 
159
174
  ## Documentation
160
175