dsh-wsl-desktop 0.3.2 → 0.3.3
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/README.en.md +56 -345
- package/README.md +54 -344
- package/lib/wsl/confinement.js +4 -4
- package/lib/wsl/fence.js +3 -3
- package/lib/wsl/preset.js +1 -1
- package/lib/wsl/subprocess.js +1 -1
- package/lib/wsl/world.js +2 -2
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -2,147 +2,101 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.md)
|
|
4
4
|
|
|
5
|
+
Turns a WSL distribution into a **first-class execution world** inside DSH Desktop: a workspace can live inside the distro, and shell, file tools, subprocess and terminal then all run inside the distro — while Windows workspaces in the same instance keep using the host's own sandbox backends. The two coexist.
|
|
6
|
+
|
|
5
7
|
> **Production status: usable in production under controlled single-machine operation** (see the scope boundaries below). Requires **DSH Desktop 0.1.7+** (presets are registered at runtime rather than loaded from directories; an outdated host explicitly rejects at activation). Core capabilities have passed live acceptance; security audits run-1 (5 candidates, closed loop) and run-2 (4 confirmed fixed + 1 rejected, converted to hardening) are complete, with reports in `security-audit-skill/dsh-wsl-desktop/run-{1,2}/`. Per-item evidence strength is annotated in the capability table below.
|
|
6
8
|
>
|
|
7
9
|
> **Scope boundaries**:
|
|
8
|
-
> - ✅ **Suitable**: the plugin author themselves or operators of the same trust level, for long-term use on explicitly supported distros (see
|
|
9
|
-
> - ⚠️ **Conditional**: the confined-mode trust boundary depends on NO_NEW_PRIVS (modern debian-family setpriv satisfies it; where setpriv has no `--no-new-privs`, confined mode does NOT run at all — the direct runner refuses once it measures `false`, and the helper's drop passes the same flag and fails closed, see
|
|
10
|
-
> - ❌ **Not yet suitable**: distribution to third-party users (missing desktop version gating and distro matrix
|
|
10
|
+
> - ✅ **Suitable**: the plugin author themselves or operators of the same trust level, for long-term use on explicitly supported distros (see the [distro support matrix](docs/DISTRO-SUPPORT.md)) and a 0.1.7+ desktop.
|
|
11
|
+
> - ⚠️ **Conditional**: the confined-mode trust boundary depends on NO_NEW_PRIVS (modern debian-family setpriv satisfies it; where setpriv has no `--no-new-privs`, confined mode does NOT run at all — the direct runner refuses once it measures `false`, and the helper's drop passes the same flag and fails closed, see [Security and trust boundaries](SECURITY.md)).
|
|
12
|
+
> - ❌ **Not yet suitable**: distribution to third-party users (missing desktop version gating and distro matrix), or unattended high-value environments (the unconfined subprocess surface is a disclosed design; the two API proposals to the harness upstream are in [docs/UPSTREAM-PROPOSALS.md](docs/UPSTREAM-PROPOSALS.md)).
|
|
11
13
|
|
|
12
|
-
##
|
|
14
|
+
## Contents
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
- **This page**: [Target capabilities](#target-capabilities) · [Install / Update / Uninstall](#install--update--uninstall) · [Quick start](#quick-start) · [Desktop update discipline](#desktop-update-discipline-mandatory) · [Security and trust boundaries](#security-and-trust-boundaries) · [Known limitations](#known-limitations) · [Verification](#verification) · [Development](#development) · [Release](#release)
|
|
17
|
+
- **In-depth material**: [Security and trust boundaries](SECURITY.md) · [Architecture and the execution world](docs/ARCHITECTURE.en.md) · [Confinement (Linux side)](docs/CONFINEMENT.en.md) · [The fs fence](docs/FS-FENCE.en.md) · [Terminal (PTY bridge)](docs/PTY-BRIDGE.en.md) · [Verification and the SKIP ruling](docs/VERIFICATION.en.md) · [Engineering notes](docs/ENGINEERING-NOTES.en.md) · [Distro support matrix](docs/DISTRO-SUPPORT.md) · [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) · [Architecture and flow diagrams](docs/diagrams.md)
|
|
18
|
+
|
|
19
|
+
> The six documents this restructure created have an English edition (linked above; the file names carry an `.en` marker) and the pair is enforced rather than trusted. The three older documents — the distro support matrix, the upstream proposals and the diagram index — are **Chinese-only**, because no English text for them exists: translating them is a separate piece of work, not a rename.
|
|
15
20
|
|
|
16
21
|
## Target capabilities
|
|
17
22
|
|
|
18
|
-
**Honest state after live acceptance** (`verify-post-restart.mjs` all green +
|
|
23
|
+
**Honest state after live acceptance** (`verify-post-restart.mjs` all green + 18 offline suites; each item below notes its evidence strength):
|
|
19
24
|
|
|
20
25
|
| # | Capability | Status |
|
|
21
26
|
|---|---|---|
|
|
22
27
|
| ① | The workspace can pick a Linux directory inside a WSL distro | Dialog and host calls implemented and live-verified (listDir/checkPath/resolveHome/workspace registration and cleanup); **in-browser interaction manually confirmed by the operator** (gating flow, directory browsing, and terminal panel all normal) |
|
|
23
28
|
| ② | shell / file tools / subprocess / terminal inside that workspace work in WSL, and host commands can be invoked from WSL | **Live acceptance PASS**: binding (the create request names the preset), shell inside the distro, fs tools addressing Linux paths, out-of-bounds write rejection, subprocess POSIX environment, bash tool, tool-layer constraints; terminal transport covered by the PTY suite |
|
|
24
29
|
| ③ | Windows and WSL workspaces coexist in the same instance | **Live acceptance PASS**: Windows workspaces stay on the host preset (PowerShell available, no bash); WSL sessions run the confined realm in parallel |
|
|
25
|
-
| ④ | Linux-side sandbox constraints on the WSL side | **Shell side fixed and live-verified**: at runtime, all `rw` mounts are enumerated and remounted read-only one by one (observed `/mnt/c`, `/dev/shm`, `/run/user/<uid>` going WRITABLE → READONLY); any failure rejects (exit code 97 preserved); `enforcement` honestly reports `partial`. **fs-side fence implemented and live-verified** (out-of-bounds write rejected, PASS — see
|
|
30
|
+
| ④ | Linux-side sandbox constraints on the WSL side | **Shell side fixed and live-verified**: at runtime, all `rw` mounts are enumerated and remounted read-only one by one (observed `/mnt/c`, `/dev/shm`, `/run/user/<uid>` going WRITABLE → READONLY); any failure rejects (exit code 97 preserved); `enforcement` honestly reports `partial`. **fs-side fence implemented and live-verified** (out-of-bounds write rejected, PASS — see [The fs tool fence](docs/FS-FENCE.md)) |
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
**Which capabilities are qualified**: the evidence strength is the wording of the status column above. The `grep` / `glob` tools do not exist in a WSL session and the terminal has no `stdio.control` — both are stated under "Known limitations" rather than left out.
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
## Install / Update / Uninstall
|
|
30
35
|
|
|
31
|
-
**
|
|
32
|
-
|
|
33
|
-
- When creating a session in the W dialog, the browser half first calls the host's `wslPresetFor` to resolve the variant id, then puts `agentPreset` into the **create request** (`createBoundSession()` in `lib/client.js`). This is the only race-free seam: the preset is composed together with the session, so there is no window of "first run in the Windows world, then switch".
|
|
34
|
-
- **That field crosses two API layers, and one of them drops it.** The host's `SessionCreateRequest.agentPreset` is a real contract, but the client service wrapper `ctx.sessions.create` rebuilds its payload from `workspaceId | cwd | sessionId` alone — `agentPreset` is silently discarded, the session lands on the default preset, and only the fallback below rescues it. Creation therefore goes through the **generated remote contract** `ctx.remote.session.create({ workspaceId, agentPreset })` (with `remote` / `remote.session` injected to match), and `ctx.sessions.create` is kept only as the retreat for a host whose remote surface is unavailable. `scripts/verify-client-ui.mjs` pins both halves.
|
|
35
|
-
- The host half keeps an `api-session/added` listener (`bindWslSession` in `lib/index.js`) as a **fallback**: it only handles sessions whose "cwd is a WSL path but the creation carried no preset" (for example, sessions created from another entry point). It attempts a post-hoc `select`; on success it binds retroactively, on failure it writes `bindingLog` and warns in the host log — such entries are now **anomaly signals**, no longer part of the normal path.
|
|
36
|
-
- The binding section of `verify-post-restart.mjs` therefore reads: a non-selftest `bindingLog` entry = someone created a WSL session while bypassing the creation seam; zero entries = every session bound through the create request is fine. Final GUI-side confirmation has been done by the operator (session preset shows WSL · PTC mode; tools execute inside the distro).
|
|
37
|
-
|
|
38
|
-
## UI entry point
|
|
39
|
-
|
|
40
|
-
In the sidebar workspace title bar, to the right of the built-in "+" (add workspace), there is an extra **W** button; clicking it opens this plugin's workspace dialog: pick a distro → enter the user → browse or type a Linux directory → create and open a session.
|
|
36
|
+
**User install (production channel)**: this plugin ships as an npm package, shaped like mature plugins such as dsh-better-sidebar (an exact `files` manifest + `cordis.patch.yml` bundle patch + `dsh.client.inject` client wiring + `manifestVersion`).
|
|
41
37
|
|
|
42
|
-
|
|
38
|
+
- Plugin market / plugin manager: install `dsh-wsl-desktop@<version>` — the desktop automatically completes the pnpm wiring and the bundle patch mount; restart and it works.
|
|
39
|
+
- CLI equivalent: `plugin_manager install_bundle dsh-wsl-desktop@<version>`.
|
|
40
|
+
- **Install prerequisites**: WSL2 + the target distro meeting the [support matrix](docs/DISTRO-SUPPORT.md) — NOPASSWD sudo for the session user, bash, python3; installing the dsh-wsl-confine helper to close the fence boundary is recommended (install commands in [docs/CONFINEMENT.md](docs/CONFINEMENT.md)).
|
|
41
|
+
- **Update**: installing a new version number is enough; after a desktop major update, run `verify-post-restart.mjs` first per "Desktop update discipline".
|
|
42
|
+
- **Uninstall**: uninstalling via the plugin manager also withdraws the wsl-* presets registered by this plugin (guaranteed by the disposer lifecycle).
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
> **Version correspondence**: the current `0.3.x` line requires DSH Desktop **0.1.7+**. See "Release" for what the semantic version means — `minor` is precisely "adapted to a new desktop major", so **when the desktop major changes, check here first for the matching minor**.
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
## Quick start
|
|
47
47
|
|
|
48
|
-
"+"
|
|
48
|
+
In the sidebar workspace title bar, to the right of the built-in "+" (add workspace), there is an extra **W** button. It opens this plugin's workspace dialog; the order is: pick a distro → enter the user to enter that distro with (**leave empty = the distro's default user**) → browse or type a Linux directory → create and open a session.
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
- The sidebar title bar has no extension point, so the W button attaches as a companion node right after the "+" button: it locates itself by the SVG path geometry of the "add workspace" icon (language-independent; this icon is rendered in exactly one place in the app) and reuses that button's class to inherit the same size and hover states. The desktop once rebuilt that icon (`IconProjectAddOutline16` → `ProjectAddOutlineArtwork`, geometry completely changed), so the trigger carries a **known list of both old and new geometry generations** and prefix-matches either one — a desktop update will not make W silently disappear. When React replaces that subtree, a MutationObserver re-attaches W.
|
|
52
|
-
- **Hiding must use `display`, never `remove()`.** The observer watches `childList`, and `remove()` is itself the next trigger; the two feed each other at refresh rate (measured: 52 times in 2.6s, each with two forced layouts). When there is no room, set `display: none` — it is not a childList mutation, so it converges.
|
|
53
|
-
- **Never use a timer as a fallback.** A past `setInterval(sync, 2000)` was the seed of exactly this loop; remounts/portals are childList mutations on the observed subtree, and a timer solves no real situation.
|
|
54
|
-
- The companion button only shows while "+" is visible (when inline search expands, it hides together with the official action cluster); when a second control carries the same icon geometry, the one inside the `*_headerActions` cluster wins; when nothing can be recognized, the button stands down entirely rather than guessing.
|
|
50
|
+
Once inside browse mode the path lands directly in that user's home directory (the host resolves the user database inside the distro with `getent passwd`). If the entered user does not exist, the error is shown in place in the popup and browse mode is not entered. Switching distros — **including re-clicking the currently selected one** — re-prompts for the user, prefilled with the previously confirmed name. While a session creation is in flight, distro buttons are temporarily unclickable.
|
|
55
51
|
|
|
56
|
-
|
|
52
|
+
To be precise about the boundary: this step only decides "whose home gets browsed", **not the session identity** — the session always executes inside the distro as the distro's default user (`username` is plugin configuration and is not passed along with session creation).
|
|
57
53
|
|
|
58
|
-
|
|
54
|
+
In the sidebar workspace list, **a workspace whose path is under a WSL UNC root shows a ☁️ icon instead of the folder**. "+" keeps the deployment's own behavior (the Electron directory picker on Desktop); this plugin does not override it.
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
data process wsl.exe -e python3 -c <bridge> <fifo> <cols> <rows> <shell…>
|
|
62
|
-
stdin/stdout = terminal bytes stderr = one JSON control reply per line
|
|
63
|
-
control process wsl.exe -e bash -c 'exec 3>"$fifo"; while read -r l; do printf "%s\n" "$l" >&3; done'
|
|
64
|
-
```
|
|
56
|
+
Why the UI and its DOM layer work this way (measured conclusions such as "hiding must use `display`, never `remove()`" and "never use a timer as a fallback") is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
65
57
|
|
|
66
|
-
|
|
58
|
+
## Desktop update discipline (mandatory)
|
|
67
59
|
|
|
68
|
-
|
|
60
|
+
After every DSH Desktop update, the plugin may break because of harness internal API changes (0.1.6→0.1.7 broke four places at once). **Discipline: update the desktop → run `node scripts/verify-post-restart.mjs` → only keep going when everything is green; any red gets fixed before anything else.** That suite covers preset registration, the execution world, session binding, Windows isolation, and dialog contracts; breakage from a new host shows up on these assertions instead of silently degrading into the host world.
|
|
69
61
|
|
|
70
|
-
|
|
62
|
+
## Security and trust boundaries
|
|
71
63
|
|
|
72
|
-
- **
|
|
73
|
-
- **
|
|
64
|
+
- **Host side**: Windows workspaces keep running in DSH's own sandbox backends; this plugin does not replace them.
|
|
65
|
+
- **Shell side inside the distro**: `/` is made read-only inside a mount namespace, leaving only the workspace and a private `/tmp` writable; **a fence that cannot be established refuses to execute** — it never runs bare. Mechanism and measured preconditions: [docs/CONFINEMENT.md](docs/CONFINEMENT.md).
|
|
66
|
+
- **fs side inside the distro**: `WslFileSystem` carries its own fence, both mutation entries go through `checkedTarget`, and an out-of-bounds target is rejected with structured `FS_SANDBOX_DENIED`. See [The fs tool fence](docs/FS-FENCE.md).
|
|
74
67
|
|
|
75
|
-
|
|
68
|
+
**Disclosed unconfined surface**: the host subprocess surface being unconfined is a design decision, not an omission. **Known boundary of the fence**: the session user's retained passwordless sudo grant can be re-invoked to bypass the file fence; the two closing paths and their real reachability are in [SECURITY.md](SECURITY.md).
|
|
76
69
|
|
|
77
|
-
## Known
|
|
70
|
+
## Known limitations
|
|
78
71
|
|
|
79
|
-
`tool-fs-search` is launched via `ctx.subprocess.spawn()` (`search-core.ts:238`), but its argv[0] is the **bundled Windows rg.exe** from `@vscode/ripgrep` (`search-core.ts:174-178`) — it does not exist inside the distro and cannot work with Linux arguments. So the WSL preset removes it from the execution world, and the model in a WSL session can only use bash's `grep` / `find` (both present in the distro). This is a capability regression, not an omission — making it equivalent would require giving the WSL world its own search implementation.
|
|
72
|
+
**The grep / glob tools do not exist in a WSL session.** `tool-fs-search` is launched via `ctx.subprocess.spawn()` (`search-core.ts:238`), but its argv[0] is the **bundled Windows rg.exe** from `@vscode/ripgrep` (`search-core.ts:174-178`) — it does not exist inside the distro and cannot work with Linux arguments. So the WSL preset removes it from the execution world, and the model in a WSL session can only use bash's `grep` / `find` (both present in the distro). This is a capability regression, not an omission — making it equivalent would require giving the WSL world its own search implementation.
|
|
80
73
|
|
|
81
|
-
**The
|
|
74
|
+
**The terminal goes through the PTY bridge, and PTC's `stdio.control` (fd channel) stays explicitly rejected** — `wsl.exe` cannot forward arbitrary descriptors. How the bridge is driven, and its measured preconditions, are in [docs/PTY-BRIDGE.md](docs/PTY-BRIDGE.md).
|
|
82
75
|
|
|
83
|
-
|
|
76
|
+
**Confinement does not cover `/dev`, `/proc`, `/sys` or interop**, which is reported honestly in `enforcement: 'partial'`; and the session user's retained passwordless sudo grant can bypass the file fence — see [SECURITY.md](SECURITY.md).
|
|
84
77
|
|
|
85
|
-
|
|
78
|
+
## Verification
|
|
86
79
|
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
ctx.shell = pwsh-sandbox (host) isolate: { shell, fs, subprocess }
|
|
91
|
-
ctx.fs = fs-sandbox (host) ├─ shell-wsl → ctx.shell
|
|
92
|
-
├─ fs-wsl → ctx.fs
|
|
93
|
-
├─ subprocess-wsl → ctx.subprocess
|
|
94
|
-
└─ tool-bash / tool-fs
|
|
80
|
+
```powershell
|
|
81
|
+
node scripts/verify-all.mjs # all offline suites (18)
|
|
82
|
+
node scripts/verify-all.mjs --live # additionally the suites needing the installed plugin + a running host
|
|
95
83
|
```
|
|
96
84
|
|
|
97
|
-
|
|
85
|
+
Per-suite responsibilities, the single-suite commands, and **how the aggregate reads a SKIP** (exit code 2 is a suite's own SKIP; only a skip `DECLARED_SKIPS` names counts, and it prints the precondition it declared; an undeclared skip fails, and a dead declaration fails before any suite runs) are in [docs/VERIFICATION.md](docs/VERIFICATION.md).
|
|
98
86
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
## Confinement (Linux side, M2)
|
|
87
|
+
Distro and user are no longer hardcoded: `DSH_WSL_DISTRO` / `DSH_WSL_USER` / `DSH_WSL_HOME` can override, with defaults read live from `wsl.exe`.
|
|
102
88
|
|
|
103
|
-
|
|
89
|
+
## Development
|
|
104
90
|
|
|
91
|
+
```bash
|
|
92
|
+
git clone https://github.com/zcluo/dsh-wsl-desktop.git
|
|
93
|
+
cd dsh-wsl-desktop
|
|
94
|
+
node scripts/verify-all.mjs # full offline run
|
|
95
|
+
.\scripts\sync.ps1 # stage into the profile and re-point (developer mode: developerTools enabled; a first install needs the wiring first)
|
|
96
|
+
# restart DSH Desktop → verify-post-restart.mjs
|
|
105
97
|
```
|
|
106
|
-
workspace-write: bind <workspace> → tmpfs /tmp → remount,ro,bind /
|
|
107
|
-
read-only: tmpfs /tmp → remount,ro,bind /
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
The order is load-bearing: **bind the writable paths first, then remount the root read-only**. The other way around, new binds inherit the read-only state (verified: workspace writes fail).
|
|
111
|
-
|
|
112
|
-
Two preconditions established by measurement:
|
|
113
98
|
|
|
114
|
-
|
|
115
|
-
- **sudo grants retained after dropping privileges are a known boundary of the fence — two closing paths.** The session user keeps the passwordless sudo grant that the runner itself depends on — a confined command can re-invoke it (open a fresh `sudo -n unshare --mount` without the fence script, or run `sudo -n mount -o remount,rw /` inside the fence), thereby bypassing the file fence. Closing paths (by priority):
|
|
116
|
-
1. **Dedicated helper (recommended; installed once, but re-installed after every plugin upgrade)**:
|
|
117
|
-
```bash
|
|
118
|
-
# run as root inside the distro (adjust the path to the actual install location)
|
|
119
|
-
install -m 0755 -o root -g root /mnt/c/Users/<you>/.dsh/profiles/desktop/plugins/dsh-wsl-desktop-*/lib/wsl/dsh-wsl-confine.sh /usr/local/sbin/dsh-wsl-confine
|
|
120
|
-
echo "$USER ALL=(root) NOPASSWD: /usr/local/sbin/dsh-wsl-confine *" > /etc/sudoers.d/dsh-wsl-confine && chmod 0440 /etc/sudoers.d/dsh-wsl-confine
|
|
121
|
-
```
|
|
122
|
-
As root, the helper **always applies the full fence first**, then drops privileges and executes the command — re-invocation just re-fences from an already-fenced context, and parameter games (workspace='/') are defeated by the `/ is not read-only` postcondition. The plugin auto-detects and prefers the helper (the sudoers file authorizes only this one file), but **accepts only the exact version this plugin requires (currently v1.2)**: v1.1 did not escape its exemption pattern, so a workspace path containing a metacharacter was swept read-only and a path containing `|` could make `/mnt/c` an exempt target (leaving the Windows filesystem writable inside a confined session). **Re-run the install above after upgrading the plugin**; a mismatched helper is simply not selected, and the plugin falls back to the direct sudo-unshare runner (whose in-process builder always escaped correctly) rather than silently keeping the old fence. The requirement is an **exact match, not a minimum**, so a future helper version bump must move `HELPER_VERSION` in `confinement.js` with it — `verify-confinement.mjs` pins the two together, so a mismatch goes red.
|
|
123
|
-
|
|
124
|
-
**The helper pins its own PATH and does not depend on sudoers.** As root it calls twelve tools by bare name (getent/cut/sed/tr/mount/findmnt/grep/mountpoint/setpriv/env/bash/unshare); `env_reset` does not save it — `secure_path` replaces only the PATH the caller **exports**, while a PATH passed as a `sudo PATH=… <helper>` command-line assignment still reaches the helper when the policy permits it (measured on debian, debian-dev and arch; whether it is permitted at all is the sudoers `SETENV`/`ALL` decision — this deployment is `NOPASSWD: ALL`, for which sudo implies SETENV — which is exactly why the helper must not rely on it), and the NOPASSWD grant is argument-wildcarded. The identity gate is the worst case: it trusts the getent/cut output resolved through that PATH, so a forged answer satisfied `--uid 0 --gid 0`. The helper therefore pins PATH to `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` and `export`s it, as the **first statement it executes** (before the argument parse and the identity gate); keep your distribution's own `secure_path` as it is — the pin is the helper's own guarantee, not a replacement for it. The pin has **no fallback**: the twelve tools must resolve in those six directories, and if one does not, the helper **fails closed** (a missing getent leaves the caller record empty and the gate refuses it; a missing cut fails the pipeline under pipefail with 127, so the fence is never established; the `command -v` preflights exit 97 with the setup-failure marker). It never falls back to the caller's PATH — a fallback would restore exactly this hole.
|
|
125
|
-
|
|
126
|
-
**The helper drops a caller-supplied BASH_ENV before it starts, and does not import functions from the environment.** bash sources a caller-supplied `BASH_ENV` file BEFORE the script's first line — as root, ahead of every control in the file (the PATH pin included, which is why the pin cannot close it). Measured (bash 5.2.37): `bash script`, a `#!/bin/bash` shebang under exec and `bash -c` all source it, while `bash -p script`, a `#!/bin/bash -p` shebang and `bash -p -c` do not. Three edits follow from that measurement. The shebang is `#!/bin/bash -p`. `-p` does not remove the variable, and every descendant inherits it — the drop-side `bash -lc` is not privileged and does process it (measured: it sourced the caller's file as the session user, inside the fence) — so the exec tail drops it with `env -u BASH_ENV`, one removal on the one invocation every root-phase descendant hangs off. And because `-p` is a property of the SHELL, the fence body — a separate `bash -c` — still imported caller-exported functions: with a coordinated `mount`/`findmnt`/`mountpoint` override the sweep reported a confined system while `/mnt/c` stayed writable (measured: a silent fence bypass; faking `mount()` alone only failed closed at the postcondition by luck), so the fence body is launched with `-p` too, which makes such an override inert. `-p` on the helper's own bash also closes a second forging route that bypasses the PATH pin (measured: a forged `getent()` function made the identity gate accept `--uid 0 --gid 0`, running the command as uid 0 inside the fence; with `-p` the gate refuses it, exit 2). Severity: this is unconfined root code execution before line 1, ahead of the fence — it exceeds the root-inside-the-fence access the PATH pin closes (and root there is not a reader either — see the identity-gate paragraph below) — but its reachability is the sudoers policy's: a strictly NARROW rule does NOT imply SETENV, and an EXPORTED BASH_ENV is stripped by sudo's env_reset (measured), so the route needs the command-line-assignment spelling, which SETENV permits and an ALL match implies. The helper-side fix is unconditional on purpose: it must not depend on the deployment's sudoers either way. `HELPER_VERSION` stays v1.2, so **an un-reinstalled helper is still selected by the probe** — re-run the install above after upgrading the plugin, this time especially.
|
|
127
|
-
|
|
128
|
-
**The identity gate is fail-closed, and it is a boundary only where the deployment's sudoers does not grant SETENV.** The gate judges the caller by SUDO_USER; an empty or unset value used to **skip the check**, and the caller controls that variable: measured, `sudo -n SUDO_USER= <helper> --uid 0 --gid 0 --home /root --cwd / -- '…'` and `sudo -n env -u SUDO_USER` both ran the command as uid 0 inside the fence with `/etc/shadow` readable. An empty or unset SUDO_USER is now **refused** (exit 2; root's own direct invocation passes `SUDO_USER=root` explicitly). The gate cannot stop **forgery**: `sudo SUDO_USER=root <helper> --uid 0 --gid 0 …` is still accepted — the variable is the only identity the gate has, and `SUDO_UID` is forgeable through the same route, so a cross-check buys nothing. The gate is therefore a boundary **only where the sudoers policy does not grant SETENV** (no `ALL` match, no command-line-assignment form); where SETENV is granted it stops unintentional misuse, not forgery, and the fence drops to the **forged uid** — root **write**, not a read primitive: the read-only state is a per-mount bind remount inside the private namespace, so the forged uid 0 can `mount -o remount,rw /` and `mount -o remount,rw /mnt/c` (both measured, and a write lands on each filesystem: a root-only path, and a file created under /mnt/c) and write to the distribution's filesystem and to the Windows filesystem; only the namespace and NO_NEW_PRIVS still apply — the file fence does not.
|
|
129
|
-
2. **NO_NEW_PRIVS (automatic; the mitigation when no helper exists)**: when dropping privileges, the runtime probes for `setpriv --no-new-privs` support (modern debian-family setpriv satisfies it; verified `noNewPrivs: true` active) — setuid escalation inside the fence fails loudly. That probe has **three** outcomes: `false` (this setpriv lacks the flag) refuses with `NO_NEW_PRIVS_UNSUPPORTED`; no measured answer (the probe did not run) also refuses, with `NO_NEW_PRIVS_UNMEASURED`; only a measured `true` drops with `--no-new-privs`. A distribution whose setpriv lacks the flag therefore **never runs a confined command at all**, and installing the helper is not a way around it: the helper's own drop passes the same flag, so it fails closed too (setpriv exits 1 on the unknown option, measured) — the fix is a newer util-linux. `enforcement: 'partial'` is not this item's caveat: it holds for **every** confined run and names what the mount namespace does not govern (/dev, /proc, /sys and interop) plus the retained-sudo boundary above.
|
|
130
|
-
- **The `\xNN` escapes from `findmnt` are decoded.** `findmnt -r` encodes spaces/tabs/newlines/backslashes in TARGET as `\x20` etc. — before the fix, the sweep remounted the literal escaped name (ENOENT swallowed by `|| true`) and the postcondition tested a fake name, so mount points containing spaces stayed writable under read-only mode and exit code 97 never triggered. Both pipelines now decode before matching, with a regression that asserts read-only on a real bind target containing spaces (verify-confinement).
|
|
131
|
-
- **After entering the namespace, drop back to the original user.** Entering via `sudo` leaves euid as root; using it directly would leave root-owned files in the workspace; `setpriv --reuid --regid --init-groups` drops back to the session user (ownership covered by an assertion).
|
|
132
|
-
- **Every probe whose output gets parsed is non-login.** `resolveIdentity` / `detectRunner` / `detectNoNewPrivs` / `listLinuxDir` / `checkLinuxPath` / `resolveDistroHome` / `resolveLoginShell` / `resolveExecutable`, and the pty's python3 probe, all use `loginShell: false` — a login shell's rc prints before the probe command's output, and positional parsing would treat whatever the profile printed as uid/gid/home (with model-writable dotfiles, that equals handing setpriv's uid to an attacker). `resolveIdentity` additionally brackets the output with a `__DSH_IDENTITY__` sentinel line + an exact four-line check; a parse failure throws an error **carrying the probe's actual output** (no more silent null). Probe timeout is 60s + one transparent retry after timeout: the first cold start of wsl.exe after a desktop restart can exceed a short limit.
|
|
133
|
-
- **wsl.exe option values pass syntax validation before spawn.** At the top of `runWslShell` / `buildWslExecArgv`, distro (`DISTRO_NAME`) and username (`LINUX_USER`) reject separator characters — the safety of the exec path does not depend on wsl.exe's external, undocumented tokenization rules; checkPath also does UNC validation before any wsl.exe side effect (regression pinned in verify-world).
|
|
134
|
-
|
|
135
|
-
## Key constraints (all measured, none inferred)
|
|
136
|
-
|
|
137
|
-
1. **Workspace paths must be in UNC spelling.** `packages/workspace/workspace/src/paths.ts:16-23` rejects POSIX paths `/home/...` as illegal on win32 (root === '/'); only `C:\…` and `\\server\share` pass.
|
|
138
|
-
2. **The plugin must live inside the profile directory.** The profile's module resolver routes `@deepseek-ai/*` to the installed generation only for modules inside the profile prefix; with `link:` outside the profile, all 7 harness packages report `Cannot find package`.
|
|
139
|
-
3. **Host plugin code cannot hot-reload.** Node caches modules by URL; reinstalling into the same directory still serves old code; hence the core logic is written as pure modules independent of the harness, verified by standalone Node scripts.
|
|
140
|
-
4. **9P sharing has no hard links.** `link()` reports `ENOTSUP`; `ReplaceFileW` / `SetFileSecurityW` are local-volume Win32 APIs, meaningless on a network share — all three are covered by `WslFileSystem`.
|
|
141
|
-
5. **`appendWindowsPath = false` is a common default.** Host programs are not on PATH; absolute `/mnt/c/Windows/System32/*.exe` paths are required (`hostExecutable()`).
|
|
142
|
-
6. **At client-plugin apply time, `<body>` may not exist yet.** The client half applies during document parsing, where `document.body` is `null`; `MutationObserver.observe(document.body, …)` then throws outright and the whole apply fails, leaving the UI silently unresponsive (the first W-button version tripped exactly here). The observation root must be `document.documentElement`, with a timer fallback in addition — relying on the observer alone assumes "there will always be a childList mutation", an assumption that does not hold.
|
|
143
|
-
7. **Client-half changes hot-update; host-half changes do not.** Rewriting the currently deployed generation's `lib/client.js` in place changes the delivery rev and pushes it to the page via `/plugins/events`, **no restart needed**; changing `lib/index.js` requires a restart. `scripts/inspect-live-client.mjs` reads the bundle the host actually delivers (in-memory cache; the on-disk file is not evidence).
|
|
144
|
-
|
|
145
|
-
## Layout
|
|
99
|
+
Repository layout:
|
|
146
100
|
|
|
147
101
|
```
|
|
148
102
|
lib/index.js Host: routes, preset generation, session preset binding
|
|
@@ -165,258 +119,15 @@ scripts/verify-*.mjs Standalone verification (no harness needed)
|
|
|
165
119
|
scripts/verify-all.mjs Aggregates and runs all suites (--live adds the suites needing a running host)
|
|
166
120
|
```
|
|
167
121
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
```powershell
|
|
171
|
-
node scripts/verify-all.mjs # all offline suites (equivalent to the ones below)
|
|
172
|
-
node scripts/verify-all.mjs --live # additionally the suites needing the installed plugin + a running host
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
**How the aggregate reads a SKIP (commit `2d44dd6`)**: a suite's exit code 2 is its own SKIP (a check it could not evaluate on this machine), and the aggregate reads exit codes — it cannot see the suite's own `SKIP` block — so a skip counts **only** where `scripts/verify-all.mjs`'s `DECLARED_SKIPS` names the suite together with the **precondition** that allows it. The summary therefore reads `N/M suites passed (K declared skip: <name>)`, and each `SKIP` prints the precondition it declared; an **undeclared** skip fails the aggregate (`FAIL (undeclared skip) <suite>`, plus the block naming the fix — add an entry to `DECLARED_SKIPS`, or remove the precondition that forced the skip) and the run exits 1 — so exit 0 can no longer coexist with "one suite never ran". A declaration whose precondition does not hold here is **dormant**: the suite runs, one extra `NOTICE` line is printed, nothing reddens. A **dead** declaration (the suite was renamed / its source no longer has an exit-2 branch / it is not in the suite list) fails **before any suite runs**, because that rot is the repository's own and an operator can clear it on the spot. The whole ruling is pinned by `scripts/verify-all-skip.mjs`, which builds copies of the aggregate over fixture suites (outside the repository) and reads real processes' real exit codes and summaries. It also re-derives both directions of the table from the real `STANDALONE` list and the suites' own exit-2 **shape**: a suite that can skip without a declaration is named, and so is a declaration whose suite no longer carries that shape.
|
|
122
|
+
Host-code changes require **restarting DSH Desktop** to load; after a restart, run `verify-post-restart.mjs` first — when old modules are still in effect it reports "please restart" directly instead of giving a false green. **Browser-half changes need no restart**, but "the file changed" is not "the served bytes changed" — the full boundary of hot reload, together with `sync.ps1`'s "keep the two newest" retention rule, is in [docs/ENGINEERING-NOTES.md](docs/ENGINEERING-NOTES.md).
|
|
176
123
|
|
|
177
|
-
|
|
124
|
+
How the execution world is decided by the session's agent preset, where the session-binding seam is, and the DOM-layer implementation of the UI entry point are in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
178
125
|
|
|
179
|
-
|
|
180
|
-
node scripts/verify-world.mjs # path translation + executing commands in the distro + directory facts + exec boundary rejection
|
|
181
|
-
node scripts/verify-preset.mjs # preset rewriting (run against the bundled standard preset)
|
|
182
|
-
node scripts/verify-fs-fence.mjs # fs fence pure logic: containment, cross-distro, writable-root derivation
|
|
183
|
-
node scripts/verify-fs-fence-skip.mjs # the fence suite’s report when the machine has one distribution: the precondition, every unevaluated assertion, the remedy, exit 2 — and all four assertions still run with a second
|
|
184
|
-
node scripts/verify-9p.mjs # 9P sharing primitive profiling + identity mapping (a load-bearing fence assumption)
|
|
185
|
-
node scripts/verify-9p-skip.mjs # the 9P probe's report when the machine has one distribution: its content, exit 2, and that it does not skip early
|
|
186
|
-
node scripts/verify-confinement.mjs # confinement fence: workspace writable, outside rejected, ownership correct, spaces in paths
|
|
187
|
-
node scripts/verify-terminal.mjs # PTY bridge: resize / foreground process group / signals / terminate
|
|
188
|
-
node scripts/verify-pty-handle.mjs # the JS terminal handle (run against the real bridge)
|
|
189
|
-
node scripts/verify-client-ui.mjs # browser-half static checks (no slot registration, locator geometry, host calls)
|
|
190
|
-
node scripts/verify-client-dom.mjs # browser-half behavior checks: the real factory run in jsdom (mount location / convergence when out of room / hiding follows)
|
|
191
|
-
node scripts/verify-modules.mjs # host-half structural pins: the distro seam + the `exports` map (`./client` is a hard contract)
|
|
192
|
-
node scripts/verify-sync.mjs # runs the real sync.ps1 against a disposable profile: four generation-retention scenarios (link resolves / dangling / no link / never staged into)
|
|
193
|
-
node scripts/verify-all-skip.mjs # the aggregate's ruling on a SKIP: an undeclared skip fails / a declared one is reported with its precondition / a dead declaration fails before any suite runs
|
|
194
|
-
node scripts/verify-route.mjs # live: the acceptance route (needs a running host)
|
|
195
|
-
node scripts/inspect-live-client.mjs # reads the client bundle the host actually delivers (accepts marker strings)
|
|
196
|
-
.\scripts\sync.ps1 # stage into the profile; an already-installed profile also gets its link re-pointed (a first install is still wired by plugin_manager)
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
Distro and user are no longer hardcoded: `DSH_WSL_DISTRO` / `DSH_WSL_USER` / `DSH_WSL_HOME` can override, with defaults read live from `wsl.exe`.
|
|
200
|
-
|
|
201
|
-
## Install / Update / Release
|
|
202
|
-
|
|
203
|
-
**User install (production channel)**: this plugin ships as an npm package, shaped like mature plugins such as dsh-better-sidebar (an exact `files` manifest + `cordis.patch.yml` bundle patch + `dsh.client.inject` client wiring + `manifestVersion`).
|
|
204
|
-
|
|
205
|
-
- Plugin market / plugin manager: install `dsh-wsl-desktop@<version>` — the desktop automatically completes the pnpm wiring and the bundle patch mount; restart and it works.
|
|
206
|
-
- CLI equivalent: `plugin_manager install_bundle dsh-wsl-desktop@<version>`.
|
|
207
|
-
- **Install prerequisites**: WSL2 + the target distro meeting the support matrix (`docs/DISTRO-SUPPORT.md`) — NOPASSWD sudo for the session user, bash, python3; installing the dsh-wsl-confine helper per the "Confinement" section is recommended (closes the retained-grant boundary).
|
|
208
|
-
- **Update**: installing a new version number is enough; after a desktop major update, run `verify-post-restart.mjs` first per "Desktop update discipline".
|
|
209
|
-
- **Uninstall**: uninstalling via the plugin manager also withdraws the wsl-* presets registered by this plugin (guaranteed by the disposer lifecycle).
|
|
126
|
+
## Release
|
|
210
127
|
|
|
211
128
|
**Maintainer release**: semantic versioning — patch = defect fixes; minor = compatible desktop-major adaptation (each harness break adaptation bumps minor, e.g. the 0.1.7 adaptation in 0.1.x); major = boundary semantics or support-matrix changes. Flow: bump the `package.json` version → full suites + `verify-post-restart.mjs` all green → `npm publish --access public` → git tag. The `files` manifest already includes `lib/wsl/dsh-wsl-confine.sh` (the helper ships with the package).
|
|
212
129
|
|
|
213
|
-
> ⚠️ **`exports["./client"]` is a hard contract and the easiest thing to delete while editing metadata.**
|
|
214
|
-
> ```
|
|
215
|
-
> DesktopHostFatalError: dsh: startup failed: 1 required plugin did not activate
|
|
216
|
-
> client-modules: dsh-wsl-desktop declares dsh.client but exports no "./client" bundle
|
|
217
|
-
> ```
|
|
218
|
-
> The v0.2.0 "release readiness" metadata rewrite (`38e6356`) deleted the whole `exports` block, which is why 0.2.0 and 0.2.1 **cannot start at all once installed**; `main` is not a substitute — Node ignores it when resolving the `./client` subpath. `scripts/verify-modules.mjs` now pins this (`./client` and `.` both present, client entry distinct from the host entry, file present on disk, platform web), so running it before a release catches the regression. **`package.json` is part of the deployed surface** alongside `cordis.patch.yml` and `lib/`: `verify-post-restart.mjs` compares the running manifest against the checkout byte for byte.
|
|
219
|
-
|
|
220
|
-
**Code install (development mode)**:
|
|
221
|
-
|
|
222
|
-
```bash
|
|
223
|
-
git clone https://github.com/zcluo/dsh-wsl-desktop.git
|
|
224
|
-
cd dsh-wsl-desktop
|
|
225
|
-
node scripts/verify-all.mjs # full offline run
|
|
226
|
-
.\scripts\sync.ps1 # stage into the profile and re-point (developer mode: developerTools enabled; a first install needs the wiring first)
|
|
227
|
-
# restart DSH Desktop → verify-post-restart.mjs
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
**Previously flaky → fixed, stable across many rounds**: `verify-terminal.mjs` used to fail intermittently (measured 1 in 6). The root cause was host-side `lib/wsl/pty.js`, not the bridge: the allocation-failure path did not terminate the already-spawned data process, and the leaked bridge interfered with later runs; control replies carried no request id, so after one timeout a late reply was consumed by the next request and the whole control channel misaligned from then on. Fix: the failure path terminates and waits for both processes to exit; every request carries an id, the bridge echoes the same id, timeout removes the entry first, and late replies are dropped outright. After the fix, a dozen-plus full-suite rounds today (including several back-to-back) reproduced nothing; the conclusion is stable.
|
|
231
|
-
|
|
232
|
-
Host-code changes require **restarting DSH Desktop** to load; after a restart, run `verify-post-restart.mjs` first — when old modules are still in effect it reports "please restart" directly instead of giving a false green. **Browser-half changes need no restart**: rewriting the current generation's `lib/client.js` in place changes the delivery rev, and the change is pushed to open pages via `/plugins/events` (`patchReload: live`). `verify-post-restart.mjs` reads the live module graph from `/plugins/events` and fetches back the bundle actually delivered, to assert exactly this. **The rev is not updated instantly, though**: the host rebuilds a client bundle lazily, so reading `/plugins/events` immediately after an in-place rewrite still returns the OLD rev (measured: it changes a few seconds later). "The file changed" is therefore not "the served bytes changed" — a verification has to wait for the new rev, or it reaches the wrong "already deployed" conclusion.
|
|
233
|
-
|
|
234
|
-
`sync.ps1` stages into a fresh timestamped directory each time (Node caches modules by URL; reinstalling into the same directory still serves old code; the stamp is second-resolution, so a second run inside the same second is bumped to a free name — the loader silently ignores a row id it has already mounted), and it **re-points the profile link at the generation it just staged**, rewriting the profile's `package.json` and `pnpm-lock.yaml` with it — otherwise the next `pnpm install` reconciles the link back to the manifest and "already staged" becomes a fiction. **The retention rule is "keep the two newest"**: the moment the link moves, "the linked generation" is the one nobody is running, while the running host resolves the one staged before it; keeping only the link would delete the generation in use on the next stage and break every WSL session until the next restart. When the link **cannot identify a generation** (the profile has no link at all, or its target is already gone) `sync.ps1` **keeps every generation and warns**, and a profile that has **never been staged into** (no `plugins/` directory at all) gets the same warning — with the link path spelled out, because that path is the only fact the reader needs. In that case it has merely put the files in place: nothing takes effect until the profile has this plugin installed (a first install is wired up by plugin_manager). The link is deleted through .NET rather than `Remove-Item`: Windows PowerShell 5.1 throws NullReferenceException on a junction, which aborted the re-point midway with the old generation still linked. `scripts/verify-sync.mjs` exercises all of this by running the real script against a throwaway profile (four scenarios: a link that resolves, a dangling link, no link, and never staged into — the last pinning that the warning names the link path).
|
|
235
|
-
|
|
236
|
-
## The fs tool fence
|
|
237
|
-
|
|
238
|
-
`WslFileSystem` carries its own fence: it declares `sandboxMode`, and both mutation entries, `writeText`/`editText`, go through `checkedTarget` first (rejections throw structured `FS_SANDBOX_DENIED`, which the tool layer maps into a model-visible `[sandbox: …]` marker plus an escalation hint); the containment comparison and writable-root derivation are pure functions in `lib/wsl/fence.js`. It does **not** inherit the official sandbox backend — the official backend would wrap this class inside a second backend, adding a layer of composition dependency for nothing, while the fence is, at bottom, "a policy check inside trusted code" and belongs in this class. (The original attribution — "the realm cannot see `sandboxPolicy`, so it cannot inherit" — was wrong; see lesson 1 below: `shell.js` in the same realm had been injecting that service all along.)
|
|
239
|
-
|
|
240
|
-
**Writable roots and the comparison namespace**: the `workspace-write` allow set = the session cwd (the workspace root) + **the distro's** `/tmp` (on the 9P share, `\\wsl.localhost\<distro>\tmp` — where a Linux-side `/tmp/…` request resolves in this world; the host POSIX `/tmp` from the official derivation is meaningless on Windows) + the Windows temp directory (reachable via `/mnt/<drive>`). The comparison happens in the **host namespace**: targetKey is in Windows spelling, and the UNC prefix carries the distro identity, so "a Linux path that happens to spell the same in another distro" cannot escape the boundary; unknown modes get an empty allow set, i.e. deny (fail-closed). `checkedTarget`'s re-normalization also starts from **targetKey**, not displayPath — displayPath is in Linux spelling without a distro, and re-resolving from it pins the path onto this class's fixed distro, silently rewriting cross-distro UNC requests (live-tripped: written into debian-dev, read from debian reported not found); now cross-distro requests get `FS_SANDBOX_DENIED` directly.
|
|
241
|
-
|
|
242
|
-
**Why the fence must exist**: `LocalFileSystem` never overrides `FileSystem.sandboxMode`. A backend that claims nothing leaves `tool-fs`'s `FsSandboxController` unable to resolve a policy for every call (`tool-fs/src/sandbox.ts:43-50`: `defaultMode === undefined` ⇒ `escalationModes = []`, `policy = undefined`), so `write`/`edit` are entirely unmanaged and can write anywhere the share reaches — including `/mnt/c` — and `toHostPath` also accepts direct `C:\…` spelling, so the actual exposure while unfenced is the entire Windows filesystem, not just the share.
|
|
243
|
-
|
|
244
|
-
**Lessons from two failed attempts** (recorded here to avoid repeating them):
|
|
245
|
-
|
|
246
|
-
1. Inheriting the official backend → the whole class never activated. At the time I attributed it to "the realm cannot see `sandboxPolicy`", **but the real cause was that I had deleted the `LocalFileSystem` import** (`node --check` parses syntax only; it cannot see undefined identifiers). Symptom matching is not attribution.
|
|
247
|
-
2. The first version of the built-in fence **denied everything** when it "could not get the workspace root", including the plugin's own `/tmp` writes. Only after tracing the mechanism did it become clear: the official backend's fallback is the same `sandboxPolicy.resolve()` (no arguments), **which itself cannot produce the workspace root either** — the root is always passed in by `tool-fs` on every call (`resolvePolicy` stamps it with the calling session's cwd).
|
|
248
|
-
|
|
249
|
-
**The selftest changed accordingly**: it now passes the policy explicitly, like a real caller: `{ mode: 'workspace-write', workspaceRoot: <session cwd> }`. It previously passed nothing — "calling fs in a way no real caller would ever use" — which is why it got rejected last time, not because the fence was too strict.
|
|
250
|
-
|
|
251
|
-
**Two pins + live acceptance**: `scripts/verify-modules.mjs` pins the fence's existence (declares `sandboxMode`, both mutation entries go through `checkedTarget`, rejections use `FS_SANDBOX_DENIED`, the containment comparison has separator boundaries); `scripts/verify-fs-fence.mjs` verifies the pure logic offline (separator boundaries, casing, **same-spelling cross-distro paths rejected**, **components that exist but cannot be canonicalized refused** (Task 2: a self-built link fixture plus five controls, the write key as the world-independent subject, and an NTFS-junction arm so the blind-arm expectations are not constants — see the next subsection), writable-root derivation, unknown modes fail-closed); `verify-9p.mjs` gained identity-mapping probes (distinct files have distinct (dev,ino); the wsl.localhost/wsl$ spellings are stable — the fence's identity fallback is load-bearing on this) and now ASSERTS the fence's refusal of the escaping spelling where it used to record a HAZARD. The class wiring is live-accepted by `verify-post-restart.mjs` (out-of-bounds write rejected, PASS). Task 2's rule, and the two suites' SKIP families, each have ONE owner below: the rule — why the final component is exempt, where it lives (`lib/wsl/fence.js`'s `isUnderHost`), its measured zero cost, the check-to-publication residue, **the two arms and the write key** the expectations are relations against, and the structural pin on `fs-local`'s publication sequence with its six mutants — in *The fence's new rule (Task 2)*; the SKIP families, with the reports that pin them and the `CROSS_DISTRO_CHECKS` list they print, in *The three share facts verify-9p records (Task 10)*.
|
|
252
|
-
|
|
253
|
-
### Measured fence facts
|
|
254
|
-
|
|
255
|
-
Several of the fence's open records cannot be settled by reading source: the 9P share's case semantics, whether `realpath` crosses a Linux symlink, whether two distributions' shares report the same `(dev,ino)`, whether the fs-fence fixture root exists — these are properties of **this machine**, not of the repository. Writing a fix for a guessed answer is writing a fix for another machine, so measure first and record after. This table is the ruling input for D8 and the four pending records (token / argv / FIFO / budget).
|
|
256
|
-
|
|
257
|
-
`scripts/probe-fence-facts.mjs` is read-only: every probe is a stat / realpath, it creates nothing and writes nothing; the second distribution is passed as argv[2], and when it is absent F3/F5 report `UNMEASURED` honestly — **UNMEASURED is a result, not a failure**. When argv[2] names the same distribution as the primary (case-insensitively) they likewise report `UNMEASURED` and say why: comparing one share against itself gives every F3 row a vacuous `COLLIDES`, and F5 would even report `true` — that is the lexical fast path containing itself, not the walk's verdict, while the annotation beside it would falsely claim the walk had short-circuited on the missing root.
|
|
258
|
-
|
|
259
|
-
Measured **2026-10-01**; primary distribution `debian`, second distribution `debian-dev`:
|
|
260
|
-
|
|
261
|
-
```powershell
|
|
262
|
-
node scripts/probe-fence-facts.mjs debian-dev
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
```
|
|
266
|
-
F1 case: /TMP resolves ENOENT -> CASE-SENSITIVE (Linux semantics)
|
|
267
|
-
F2 symlink: realpath(/lib) ENOENT -> the link is NOT followed (control /usr/lib resolves (\\wsl.localhost\debian\usr\lib))
|
|
268
|
-
F3 identity <share root> dev 0 vs 0, ino 2 vs 2 -> COLLIDES
|
|
269
|
-
F3 identity /tmp dev 0 vs 0, ino 1 vs 1 -> COLLIDES
|
|
270
|
-
F3 identity /home dev 0 vs 0, ino 16386 vs 16386 -> COLLIDES
|
|
271
|
-
F4 fixture root \\wsl.localhost\debian\home\zcluo\proj ENOENT (verify-fs-fence.mjs never creates it)
|
|
272
|
-
F5 isUnderHost(foreign target, root) false (root absent, so the walk short-circuits; see F4)
|
|
273
|
-
|
|
274
|
-
[
|
|
275
|
-
{
|
|
276
|
-
"label": "F1 case: /TMP resolves",
|
|
277
|
-
"value": "ENOENT -> CASE-SENSITIVE (Linux semantics)"
|
|
278
|
-
},
|
|
279
|
-
{
|
|
280
|
-
"label": "F2 symlink: realpath(/lib)",
|
|
281
|
-
"value": "ENOENT -> the link is NOT followed (control /usr/lib resolves (\\\\wsl.localhost\\debian\\usr\\lib))"
|
|
282
|
-
},
|
|
283
|
-
{
|
|
284
|
-
"label": "F3 identity <share root>",
|
|
285
|
-
"value": "dev 0 vs 0, ino 2 vs 2 -> COLLIDES"
|
|
286
|
-
},
|
|
287
|
-
{
|
|
288
|
-
"label": "F3 identity /tmp",
|
|
289
|
-
"value": "dev 0 vs 0, ino 1 vs 1 -> COLLIDES"
|
|
290
|
-
},
|
|
291
|
-
{
|
|
292
|
-
"label": "F3 identity /home",
|
|
293
|
-
"value": "dev 0 vs 0, ino 16386 vs 16386 -> COLLIDES"
|
|
294
|
-
},
|
|
295
|
-
{
|
|
296
|
-
"label": "F4 fixture root \\\\wsl.localhost\\debian\\home\\zcluo\\proj",
|
|
297
|
-
"value": "ENOENT (verify-fs-fence.mjs never creates it)"
|
|
298
|
-
},
|
|
299
|
-
{
|
|
300
|
-
"label": "F5 isUnderHost(foreign target, root)",
|
|
301
|
-
"value": "false (root absent, so the walk short-circuits; see F4)"
|
|
302
|
-
}
|
|
303
|
-
]
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
With no second distribution passed:
|
|
307
|
-
|
|
308
|
-
```powershell
|
|
309
|
-
node scripts/probe-fence-facts.mjs
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
```
|
|
313
|
-
F1 case: /TMP resolves ENOENT -> CASE-SENSITIVE (Linux semantics)
|
|
314
|
-
F2 symlink: realpath(/lib) ENOENT -> the link is NOT followed (control /usr/lib resolves (\\wsl.localhost\debian\usr\lib))
|
|
315
|
-
F3 cross-share identity UNMEASURED - pass a second distribution as argv[2]
|
|
316
|
-
F4 fixture root \\wsl.localhost\debian\home\zcluo\proj ENOENT (verify-fs-fence.mjs never creates it)
|
|
317
|
-
F5 isUnderHost(foreign target, root) UNMEASURED - pass a second distribution as argv[2]
|
|
318
|
-
|
|
319
|
-
[
|
|
320
|
-
{
|
|
321
|
-
"label": "F1 case: /TMP resolves",
|
|
322
|
-
"value": "ENOENT -> CASE-SENSITIVE (Linux semantics)"
|
|
323
|
-
},
|
|
324
|
-
{
|
|
325
|
-
"label": "F2 symlink: realpath(/lib)",
|
|
326
|
-
"value": "ENOENT -> the link is NOT followed (control /usr/lib resolves (\\\\wsl.localhost\\debian\\usr\\lib))"
|
|
327
|
-
},
|
|
328
|
-
{
|
|
329
|
-
"label": "F3 cross-share identity",
|
|
330
|
-
"value": "UNMEASURED - pass a second distribution as argv[2]"
|
|
331
|
-
},
|
|
332
|
-
{
|
|
333
|
-
"label": "F4 fixture root \\\\wsl.localhost\\debian\\home\\zcluo\\proj",
|
|
334
|
-
"value": "ENOENT (verify-fs-fence.mjs never creates it)"
|
|
335
|
-
},
|
|
336
|
-
{
|
|
337
|
-
"label": "F5 isUnderHost(foreign target, root)",
|
|
338
|
-
"value": "UNMEASURED - pass a second distribution as argv[2]"
|
|
339
|
-
}
|
|
340
|
-
]
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
**How to read this table** (every row is measured, none inferred):
|
|
344
|
-
|
|
345
|
-
- **F1 case-sensitive (Linux semantics)**: `/TMP` does not fold onto `/tmp` and reports ENOENT. The share itself answers (in the same table F2's control resolves and F3's stat succeeds), so this ENOENT means "the share distinguishes case", not "the share did not answer".
|
|
346
|
-
- **F2 realpath does not cross a Linux symlink**: realpath of `/lib` (→ `usr/lib`) reports ENOENT, while the link's own target `/usr/lib` resolves normally on the same share (the control is written into the probe and into this row). Per `lib/wsl/fence.js:31-37`, `canonicalHostPath` takes the catch branch for such a path and returns it unchanged.
|
|
347
|
-
- **F3 cross-distribution (dev,ino) collision**: the shares of `debian` and `debian-dev` report a **completely identical** pair for the same spelling (`/`=2, `/tmp`=1, `/home`=16386, dev 0 on both sides). The fence's identity fallback tests equality as `dev === dev && ino === ino` (`lib/wsl/fence.js:87`), so that equality test cannot tell the two distributions apart. A spelling that compared only dev would always report COLLIDES on this machine and could never ask the ino half, so the probe compares and prints the whole pair.
|
|
348
|
-
- **F4 the fixture root does not exist**: `<home>/proj` reports ENOENT, and `verify-fs-fence.mjs` never creates it.
|
|
349
|
-
- **F5 the identity walk did not run**: when the root does not exist, `isUnderHost` short-circuits to false at the stat of the root (`lib/wsl/fence.js:82-83`), so this row's `false` means "the root does not exist", not "the walk refused a cross-distribution target". **F3's collision and F5's false must not be read together as "cross-distribution containment is safe".**
|
|
350
|
-
|
|
351
|
-
**Correction (Task 4, commit `850e104`)**: F4/F5 record the state **before** Task 4 — at that time the fixture root was `<home>/proj`. `verify-fs-fence.mjs` now builds its own fixture root: a one-off `dsh-fence-fixture-<pid>-<rand>` under the distribution's `/tmp`, removed as soon as it is done (normal exit, assertion failure, `process.exit`, uncaught exception, SIGINT/SIGTERM all clean up), and it **no longer references `<home>/proj`**, so the cross-distribution pin really runs the identity walk on any machine that has two distributions. The table's numbers are **not** changed: they are that time's measurement, and the probe's output is reproducible word for word to this day — `<home>/proj` still reports ENOENT, and the suite still never creates **that** path.
|
|
352
|
-
|
|
353
|
-
**Correction (Task 9)**: F3's collision was not merely "on record" — it was a **live hole**, and the fence now closes it. The lexical fast path is the only comparison that carries the distribution, and once it fails (which is exactly the cross-distribution case) the identity walk re-stats every ancestor against **the target's own share**, so a foreign target's ancestors are compared with the local root's `(dev,ino)`. The distribution's `/tmp` is a writable root `workspace-write` **always** grants (`writableHostRootsFor`), and both shares report the same pair for it — measured `isUnderHost('\\wsl.localhost\debian-dev\tmp\x', '\\wsl.localhost\debian\tmp') === true`: a cross-distribution write judged "contained". The fix binds the walk to the distribution (the same rule as `contains()`: if **both** sides resolve to a WSL UNC and the distributions differ, refuse — the distribution segment compared case-insensitively in Windows spelling), and after the fix that call is `false`. The rule is deliberately kept **narrow**: a drive-letter path carries no distribution, so drive-letter targets and drive-letter roots keep the identity verdict they had (the suite pins both directions), and a drive letter and a share are measured to be unable to collide (the Windows temp directory's dev is the NTFS volume serial number 3764601112, while every 9P share reports 0). `verify-fs-fence.mjs` gains two pins: a cross-distribution target under a share-identity root must be refused (this one **can only** pass when the walk did not run — running it means authorization, so it is the proof that "the walk was not reached"), and a case-variant spelling of the same distribution (`wsl$` plus an upper-case distribution) must still be contained (the binding must not refuse a legitimate target in the other direction).
|
|
354
|
-
|
|
355
|
-
### The three share facts verify-9p records (Task 10)
|
|
356
|
-
|
|
357
|
-
Task 1's `probe-fence-facts.mjs` is a **one-off measurement**: it records the answers in the table above, but it does not enter the aggregate. The three share facts the fence actually depends on — symlinks, cross-distribution identity, case — are now also measured by `scripts/verify-9p.mjs`, which **is in `verify-all.mjs`'s `STANDALONE` list**: the offline aggregate runs it every time, so these three facts are re-measured on every full run.
|
|
358
|
-
|
|
359
|
-
**But on a machine with only one distribution only two of them are re-measured.** The three cross-distribution identity `FACT` rows need a **second** distribution; with only one, the suite **skips** those three: the `SKIP` line names the missing precondition, **lists the three facts one by one**, gives the remedy (install a second distribution, or point `DSH_WSL_OTHER_DISTRO` at one already on this machine), and **ends with exit code 2** — this skip is declared in `DECLARED_SKIPS` (that entry carries the precondition above, alongside "the link fixture cannot be built"), so `verify-all` reports it as `SKIP` and prints that precondition instead of counting it as `PASS`; a green aggregate cannot say these three facts are established, and an undeclared skip is judged red (see the "Verification" section). This is the owner's ruling: a single distribution is a **missing precondition**, not a bad profile. The skip is **limited to that family** — the facts that do not need a second share (symlinks, case) are still measured and printed, so what the reader loses is exactly the three that are named. The **content** of that `SKIP` is pinned by `scripts/verify-9p-skip.mjs`: it forces the precondition out through the probe's own override, runs a real process and reads real output (so every assertion runs on **every** machine), and pins the control as well — with a second distribution the suite still measures all three and exits 0.
|
|
360
|
-
|
|
361
|
-
**The same family has one more member.** When the link fixture cannot be built (wsl.exe fails, or a cold start exceeds its own 30s ceiling) the suite skips not only the three symlink facts but **also that assertion** — "the fence refuses the target spelling its own canonicalization produces", the product of turning the HAZARD into an assertion, which no other check in this suite covers. This family counts too (3 facts + 4 checks) and **exits 2**: disclosing it while staying green is exactly the class this round removes.
|
|
362
|
-
|
|
363
|
-
**The fence suite has the same family.** `scripts/verify-fs-fence.mjs` carries a family of **four cross-distribution assertions** that likewise need a second share: three with across-share subjects plus a **control** (a case-variant spelling of the same distribution must still be contained — a distribution binding that compared the segment case-sensitively would refuse a legitimate target, which is worse than the defect it closes). With only one distribution not one of the four can be evaluated, and this used to be **the precondition check FAILing, exit 1** — the owner ruled for a SKIP: the `SKIP` line names the precondition (no second distribution / the override pointed at itself / an empty override / a resolved share that does not answer), **lists the original label of each of the four unevaluated assertions**, says what is therefore unestablished, gives the remedy, **counts 4** and **exits 2** — this skip is likewise declared in `DECLARED_SKIPS` (that entry lists this precondition), so `verify-all` reports `SKIP` and prints the precondition instead of counting it as `PASS`; an undeclared skip is judged red. Those four labels are no longer described by prose but come from the **single list `CROSS_DISTRO_CHECKS`**: the checks print it and the `SKIP` prints it, so the words cannot drift from "what actually did not run" (the old text, "the two cross-distribution assertions", both miscounted and named none of them). This family's **content and both machine classes** are pinned by `scripts/verify-fs-fence-skip.mjs` (in `STANDALONE`, so it runs on every aggregate): it forces the precondition through the suite's override (pointed at the selected distribution / empty), simulates the owner's machine with a copy of the tree whose `listDistros()` reports one distribution, and reads a real child's stdout and exit code; the **control** proves that with a second distribution the suite still exits 0, prints no `SKIP` of this family, and prints all four assertions **PASS one by one**; and if the **distribution binding is removed** from `lib/wsl/fence.js` in the copy, that share-identity assertion must redden — an assertion is not a constant, and that is a proof rather than a claim.
|
|
364
|
-
|
|
365
|
-
**Division of labour: one owner per thing.** `verify-9p.mjs` records **how the share answers** (`FACT` rows, not assertions); **how the fence answers** is pinned in `scripts/verify-fs-fence.mjs` — the case row asserts adaptively against the share's own answer (`isUnderHost(case variant) === foldsCase`), and the cross-distribution row asserts that a foreign target under a share-identity root must be refused. Asserting the share's answer again in the profiling probe would redden on a machine whose **answers differ but which is healthy**, the same class of defect as "a check that can never fail".
|
|
366
|
-
|
|
367
|
-
**But a fact row cannot be "print a sentence and be done"**: beside every fact it first asserts the two things that make it a measurement — **the subject exists** and **the control answers** (the link the probe itself built appears in the share's listing, the link's own target is readable, `/lib` is in the share's listing, `/tmp` exists). Without those two, an ENOENT from a path that never existed would be read as "the share refused the link" — exactly the hollow pin this plan removes (D2). The falsifiability of these three assertions is proven with mutants (a wrong link name / a wrong control file name / replacing the creation with a rename primitive the share **does** resolve): each mutant reddens **only** its own row, exit code 1.
|
|
368
|
-
|
|
369
|
-
Measured (2026-10-01, primary distribution `debian`, second distribution `debian-dev`):
|
|
370
|
-
|
|
371
|
-
```powershell
|
|
372
|
-
node scripts/verify-9p.mjs
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
```
|
|
376
|
-
FACT realpath(/lib), a merged-/usr symlink — ENOENT -> the link is NOT followed (control /usr/lib resolves (\\wsl.localhost\debian\usr\lib))
|
|
377
|
-
FACT realpath / read of the link (the file behind it exists) — realpath ENOENT; read ENOENT -> the link is exposed but NOT followed
|
|
378
|
-
FACT a rename whose destination traverses the link — rename accepted without error and \\wsl.localhost\debian\tmp\dsh-wsl-9p-probe-link\outside\renamed-dst.txt exists: true -> the file landed AT the link's target (the SHARE resolves the destination spelling; the fence refuses it — the assertion below — and the provider never reaches this primitive anyway: the mkdir below aborts first, fs-local/src/fsio.ts:598)
|
|
379
|
-
FACT mkdir through the link, at a spelling the share resolves elsewhere — mkdir reported EINVAL and \\wsl.localhost\debian\tmp\dsh-wsl-9p-probe-link\outside\dsh-link-dir exists: true; isUnderHost(the raw spelling) === false
|
|
380
|
-
OK the fence refuses the target its own canonicalization produces for that spelling
|
|
381
|
-
FACT cross-share identity <share root> — debian (0,2) vs debian-dev (0,2) -> COLLIDES - the identity comparison cannot tell the two shares apart
|
|
382
|
-
FACT cross-share identity /tmp — debian (0,1) vs debian-dev (0,1) -> COLLIDES - the identity comparison cannot tell the two shares apart
|
|
383
|
-
FACT cross-share identity /home — debian (0,16386) vs debian-dev (0,16386) -> COLLIDES - the identity comparison cannot tell the two shares apart
|
|
384
|
-
FACT case-variant path /TMP (control: /tmp exists) — ENOENT -> CASE-SENSITIVE (Linux semantics)
|
|
385
|
-
|
|
386
|
-
THE 9P PROFILE MATCHES WHAT THE PROVIDER ASSUMES
|
|
387
|
-
8 share fact(s) recorded above — NOT assertions: the fence's answers to them are pinned in verify-fs-fence.mjs
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
**F2's full answer: what the share actually does with a symlink.** Task 1's F2 measured only `realpath`. The same probe now builds its own link in the fixture root with `ln -s` (the target lies **outside** the fixture root; both directories belong to the probe and are removed afterwards), so every answer has a subject that is **definitely a link**:
|
|
391
|
-
|
|
392
|
-
- `realpath` / `stat` / `read` / `readdir` / plain creation (`open` without `O_EXCL`) **all fail to cross the link** (ENOENT) — that is the half the fence assumes.
|
|
393
|
-
- `rename` (destination under the link) and `mkdir` (creating a new directory under the link) **are resolved by the server**: the rename really lands at the link's target; mkdir reports `EINVAL` on the client while **the directory is created at the link's target**.
|
|
394
|
-
- A final-component symlink is **safe**: the rename replaces the link entry itself inside the root (measured: the link's target file content is unchanged), and exclusive creation reports `EEXIST`.
|
|
395
|
-
- The Windows side cannot even delete the link entry itself: `unlink` → ENOENT, `rm` → EISDIR, `rm -r` on a directory containing a link → ENOTEMPTY. So the fixture must be cleaned up with `wsl.exe ... rm -rf`, which is why `exit`/`SIGINT`/`SIGTERM` handlers are attached (what the Windows side cannot delete must not be left for the user) — that is not fastidiousness, it is a direct consequence of this fact.
|
|
396
|
-
|
|
397
|
-
### The fence's new rule (Task 2): a component that exists but cannot be canonicalized is refused
|
|
398
|
-
|
|
399
|
-
**The rule (one sentence, falsifiable)**: a target's containment verdict is true if and only if **every path component** between the "writable root" and "the target's own file name" either **does not exist** on the share (`lstat` reports ENOENT/ENOTDIR) or **can be canonicalized** (`realpathSync.native` succeeds); a component that **exists but cannot be canonicalized** (measured on this machine: a Linux symlink where `lstat` reports EISDIR and both `realpath` and `stat` report ENOENT) refuses the target outright. The target's **own file name is not inside the rule**.
|
|
400
|
-
|
|
401
|
-
**That HAZARD is therefore closed**: the original record was "the fence authorizes a spelling the share resolves elsewhere, and the publication's first action, `mkdir(directory, {recursive:true})` (`fs-local/src/fsio.ts:598`), creates the missing directory level at the link's target — outside the writable root". After the rule landed, the same spelling's measurement in `verify-9p.mjs` went from `isUnderHost(...) === true` to `false`, and that HAZARD row became a real assertion ("the fence refuses the target its own canonicalization produces"). **The FACT row is still true**: the share-side mkdir still creates the directory at the link's target (that is the share's behaviour); what changed is only the fence's answer to it.
|
|
402
|
-
|
|
403
|
-
**Why (b) and not (a)**: the candidate rule (a), "refuse a target whose path **component** exists but does not resolve", literally includes the **final component**, and a final component that is a link is **measured safe** — the publication's rename replaces the link entry itself inside the root, the link's target file content is unchanged (F2's third row above), and it **works today** (`verify-fs-fence.mjs`'s control pin "a final-component file link / directory link is still authorized"). Refusing it would refuse a legitimate write that works, and "a rule that refuses legitimate same-root writes is worse than the gap it closes". Rule (a) also draws no root boundary, and read literally it would refuse everything because some component **above the root** cannot resolve. The chosen (b) pins the scope to "between the root and the target's file name" — exactly the set of components line 598's `mkdir` walks, and would create.
|
|
404
|
-
|
|
405
|
-
**Where the rule lives**: in `lib/wsl/fence.js`'s `isUnderHost` (new helpers `canonicalizationOf` / `componentsCanonicalize`), **not** in `checkedTarget`. Three reasons: one, the authorization verdict *is* `isUnderHost`'s answer (`checkedTarget` merely calls it for every writable root and treats it as the authorization), so putting the rule elsewhere would leave `isUnderHost('<root>\<link>\...', root)` returning `true` — and that expression is exactly the measured subject of the original HAZARD, so that would only "comment out" the gap, not close it; two, the rule needs a root boundary and `isUnderHost` already has one (lexical prefix plus identity walk); three, both paths (the lexical fast path and the identity fallback) must pass through it, otherwise the `wsl$` alias spelling would bypass the rule — the alias pin exists for exactly that. `checkedTarget` and the two mutation entries are unchanged word for word.
|
|
406
|
-
|
|
407
|
-
**The cost (measured, not reasoned)**: canonicalization cannot tell a link pointing **inside** from one pointing **outside**, so both are refused. The cost is **zero** — a write through a link **cannot publish on this share anyway**: `mkdir(directory, {recursive:true})` reports ENOENT for a path through a link (measured, including when the intermediate directories already exist), so the write never reaches the rename; the fence's refusal only replaces "leave an out-of-bounds directory behind and report ENOENT" with "refuse before creating anything". `readlink` does not help either: it reports EISDIR for a link entry (measured) and cannot obtain the link's target.
|
|
408
|
-
|
|
409
|
-
**Residue**: the **race between the check and the publication**. The rule can only refuse components that **already exist**; a link planted by a bash tool after `checkedTarget` passes and before line 598's `mkdir` is still invisible (canonicalization is blind to such a component by construction, and no check can see it). The window is sub-millisecond and the payoff is still only an out-of-bounds directory creation with no content leak.
|
|
410
|
-
|
|
411
|
-
**Pins and mutants**: `verify-fs-fence.mjs` builds its own link fixture (`escape` pointing out of bounds, `inside-link` pointing in bounds, `dangling` pointing at a nonexistent target, `file-link` pointing at an out-of-bounds file; built with the distribution's `ln -s` and removed with `wsl.exe rm -rf`, because the Windows side can neither create nor delete link entries), asserts the rejection matrix (an out-of-bounds ancestor, **the key the write is actually handed**, the target's own parent, an in-bounds link, a dangling link, the `wsl$` alias spelling) **plus 5 controls** (a missing component under a real directory, a directly missing component under the root, an existing directory, a final-component file link, a final-component directory link — all of which must still be allowed); when the fixture cannot be built it **FAILs first and only then skips**, and does not permit "the link does not exist, so the assertion passes vacuously".
|
|
412
|
-
|
|
413
|
-
**"The key the write hands over" is not `canonicalHostPath`**: it is the key fs-local's own resolution walk produces (`resolveLocalTarget`, `fs-local/src/fsio.ts:161-210`), mirrored in the suite as `writeTargetKey`. `canonicalHostPath` runs `realpathSync.native` over the **whole path** with a fall-back to the input, and every target in this section has a nonexistent tail — so on **any** share it returns its input unchanged, and "are the two spellings the same" is simply not a function of blindness (R=1 wrote a pin from that reasoning that **would red falsely**: a true proposition written as a false failure). With the write key the relation holds: under the **blind arm** the walk stops at the fixture root and the key is the raw spelling; under the **resolving arm** the walk crosses the link and the key is the link's target; both arms are refused by containment.
|
|
414
|
-
|
|
415
|
-
**Two arms, and neither may be reddened**: what the fence answers depends on whether its canonicalization can see through the link, so the expectations above are **not constants** — they are written as relations against the measured `blindTo(link)` (`=== !blind`, the same shape as the case row's `=== foldsCase`). The other arm (canonicalization **can** see through the link) is buildable on this machine too: an NTFS directory junction is a reparse point `realpathSync.native` **does** resolve (measured), needs no privilege, and `rmSync` removes only the link itself (measured: the target is unaffected), so it is **pinned** rather than argued — under that arm the fence refuses the write key (containment, not blindness), while the raw spelling **is** in bounds: this is exactly where "writing the blind-arm expectation as a constant" would redden on a healthy machine.
|
|
416
|
-
|
|
417
|
-
**The cost row has a pin too**: `fs-local`'s publication sequence is "first `mkdir(directory, {recursive:true})`, and that step is outside **any** try/catch that could catch it" (structural, not incidental), so when it fails the staging directory, the temp file and the rename have not happened yet — which is also why that out-of-bounds trip left only a directory and nothing else. It is pinned **structurally** in `verify-fs-fence.mjs`: it reads the harness checkout's `packages/fs/fs-local/src/fsio.ts` (comments and string bodies blanked with the shared `blankLiterals` first, so a mention of the call in a doc comment does not count; located with `--checkout=` / `DSH_CHECKOUT`, defaulting to the checkout beside this repository) and asserts that the call is a **bare await statement** with all four criteria holding: what immediately precedes the call must be `await` (`head`), what follows it on the same line may only be blank or `;` (`tail`, comments being blanked to whitespace so a trailing comment does not count), there must be no try/catch from the **start of the function** to the call (`beforeCreate`, the window measured from the **function's opening**, not from the `const directory` line — otherwise "open a try before the call, put the catch after it" would slip through), and none between the call and the next `try {` (`toNextTry`). It does **not** claim: that this `mkdir` is the real binding (shadowing is a different defect), or that the caller will not swallow the function's own rejection — both are outside the structural pin's boundary. It also asserts that the try being guarded **is** the staging sequence (the boundary is limited to that try through the end of the function; it does **not** claim no other code path can reach those two calls). When the checkout is unreadable it **counts a skip and makes the whole suite report SKIP with exit code 2** (as its sibling suites do; the suite declares the precondition "the harness checkout is unreadable (or no second distribution's share answers)" in `DECLARED_SKIPS`, so `verify-all` reports `SKIP` and prints it instead of counting it as a pass) and does not let a green aggregate hide an unrun pin. **All six mutants** were run on **copies** (the real file must stay green): moving the call inside the guarded try → 2 red; wrapping it in its own try → 1 red; opening a try before the call with the catch after it → 1 red; **holding the promise and awaiting it later** (`const p = mkdir(…)` followed by `try { await p } catch {}`) → 1 red (caught only by `head`); a **`.catch(() => {})` chain** (no try/catch keyword anywhere) → 1 red (caught only by `tail`); a **late-registered catch** → 1 red; the harness checkout is byte-identical (`D7CC70E0…`).
|
|
418
|
-
|
|
419
|
-
That line in `verify-9p.mjs` was changed to a real assertion. Fence-side mutants: removing only the lexical fast path half → `verify-fs-fence.mjs` reddens only its 5 lexical pins (the alias row stays green), `verify-9p.mjs` reddens 1, exit 1; removing only the identity walk half → only the alias row reddens; removing both halves = the state before the change → 6 red. After every mutation the file is restored byte-identically (blob `3a353748…`).
|
|
130
|
+
> ⚠️ **`exports["./client"]` is a hard contract and the easiest thing to delete while editing metadata.** One package that declares `dsh.client` without `exports["./client"]` refuses to compose, and the **whole desktop fails to start** (not just this plugin) — which is why 0.2.0 and 0.2.1 **cannot start at all once installed**. `scripts/verify-modules.mjs` now pins this, so running it before a release catches the regression; the incident is recorded in [docs/ENGINEERING-NOTES.md](docs/ENGINEERING-NOTES.md).
|
|
420
131
|
|
|
421
132
|
## License
|
|
422
133
|
|