dsh-wsl-desktop 0.3.0 → 0.3.2

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 CHANGED
@@ -6,7 +6,7 @@ English | [简体中文](README.md)
6
6
  >
7
7
  > **Scope boundaries**:
8
8
  > - ✅ **Suitable**: the plugin author themselves or operators of the same trust level, for long-term use on explicitly supported distros (see "Distro support matrix") and a 0.1.7+ desktop.
9
- > - ⚠️ **Conditional**: the confined-mode trust boundary depends on NO_NEW_PRIVS (modern debian-family setpriv satisfies it; on old setpriv builds without support, the fence can be pierced by the model's own sudo grant, see caveats).
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 caveats).
10
10
  > - ❌ **Not yet suitable**: distribution to third-party users (missing desktop version gating and distro matrix — see the production roadmap and `docs/DISTRO-SUPPORT.md`), 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`).
11
11
 
12
12
  ## Desktop update discipline (mandatory)
@@ -126,10 +126,10 @@ Two preconditions established by measurement:
126
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
127
 
128
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. On old setpriv builds without that flag, this boundary still exists — honestly recorded in the `enforcement: 'partial'` caveats.
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
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
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` / `listLinuxDir` / `checkLinuxPath` / `resolveDistroHome` / `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.
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
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
134
 
135
135
  ## Key constraints (all measured, none inferred)
@@ -248,13 +248,175 @@ Host-code changes require **restarting DSH Desktop** to load; after a restart, r
248
248
 
249
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
250
 
251
- **The canonicalization rule (Task 2)**: the containment verdict also refuses a target whose path traverses a component that EXISTS but cannot be canonicalized. Measured on this share: `realpathSync.native` — the fence's canonicalizer, and the call `resolveLocalTarget` makes (`fs-local/src/fsio.ts:161-210`) — answers ENOENT for a Linux symlink entry, while `lstat` answers EISDIR and `readdir` lists the entry; the share's own `mkdir(directory, {recursive:true})` (`fs-local/src/fsio.ts:598`) *does* resolve that spelling and creates the missing level AT THE LINK'S TARGET, outside the writable root. The rule: every component strictly between the writable root and the target's own name must either not exist (`lstat` ENOENT/ENOTDIR) or canonicalize. The target's own name is exempt — a final-component link is measured safe (the publication's rename replaces the link entry inside the root and the link's target file is untouched) and it works today, and a rule that refuses legitimate same-root writes is worse than the gap it closes. The rule lives in `lib/wsl/fence.js` (`isUnderHost`, through the new `canonicalizationOf` / `componentsCanonicalize`), on BOTH the lexical fast path and the identity walk, so the `wsl$` alias spelling cannot bypass it; `checkedTarget` and the two mutation entries are unchanged. The measured cost is zero: a write through either kind of link cannot publish on this share at all (the recursive `mkdir` reports ENOENT for a path through a link, even when the intermediate directories exist), so the refusal replaces a stray out-of-bounds directory followed by ENOENT with a refusal BEFORE anything is created. What remains is the check-to-publication race: a link planted between `checkedTarget` and that `mkdir` is invisible to any check, because the canonicalizer is blind to it.
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
252
 
253
- **Two arms, and neither may be reddened**: what the fence answers depends on what its canonicalizer can DO with the link, so the blind-arm expectations are written as relations against the measured blindness (`=== !blind`, the shape the case-variant pin already used) rather than as constants. The resolving arm is pinned too: an NTFS directory junction is a reparse point `realpathSync.native` RESOLVES (measured), needs no privilege, and the suite removes it without following it (measured). The world-independent subject is the **WRITE KEY** — the key a write actually hands the fence, produced by fs-local's own resolution walk (`resolveLocalTarget`, mirrored in the suite as `writeTargetKey`); `canonicalHostPath` is NOT that key (it realpaths the whole path and falls back to the input, so a missing tail makes it return its own spelling on any share — using it as the write's key is what made a first-round claim unsound).
253
+ ### Measured fence facts
254
254
 
255
- **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 that must stay authorized, the write key as the world-independent subject, and an NTFS-junction arm so the blind-arm expectations are not constants), 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 zero-cost half of the rule is pinned STRUCTURALLY against the harness checkout's `packages/fs/fs-local/src/fsio.ts`: the call must be a BARE AWAITED statement — `await` immediately before it, nothing but blank (or `;`) after it on its line, no try/catch between the function's opening and the call (so a guard opened earlier cannot slip through), and none between the call and the next `try {` — and the guarded region must be the staging one. Six mutations run on copies redden it (the unmutated file must stay green) and the checkout is never touched; an unreadable checkout is a counted SKIP and the suite exits 2, so `verify-all` shows SKIP — the suite is declared in `DECLARED_SKIPS` with that precondition, which the summary prints — instead of a green suite with an unrun pin. The class wiring is live-accepted by `verify-post-restart.mjs` (out-of-bounds write rejected, PASS). And `verify-9p.mjs` SKIPS its three cross-share identity rows when the machine has only ONE distribution — the owner's ruling: a missing precondition, not a broken profile. The SKIP names the missing second share, the three facts that therefore went unmeasured and the remedy, and the suite exits 2, so `verify-all` reports SKIP (declared in `DECLARED_SKIPS` with that precondition, which is printed) instead of a pass: a green aggregate cannot imply those facts were established. Only that family is skipped (the symlink and case facts need no second share and are still measured and printed). The report's CONTENT is pinned by `scripts/verify-9p-skip.mjs`, which forces the precondition through the probe's own override and reads the real child's stdout and exit code, so its assertions run on every machine — and it pins the control too: with a second distribution present the probe still measures all three rows and exits 0. The one other family it SKIPs is the link fixture: when it cannot be built (a failed or timed-out `wsl.exe`) the run loses not only the three symlink facts but the assertion that fixture EXISTS for — the fence's refusal of the spelling its own canonicalization produces, which no other check covers — so those are counted too (3 facts + 4 checks) and the suite exits 2.
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
256
 
257
- **The fence suite has the same family.** `scripts/verify-fs-fence.mjs` carries **four cross-distribution assertions** that need the same second share: three with across-share subjects plus a **control** (a case-variant spelling of the SAME distribution must stay contained — a binding that compared the distribution segment case-SENSITIVELY would refuse a legitimate target, which is worse than the defect it closes). On a one-distribution machine none of them can be evaluated, and the precondition used to FAIL the suite (exit 1); the owner ruled for a SKIP: a `SKIP` line names the precondition (no second distribution installed / the override pointed at the selected distribution / an empty override / a resolved share that does not answer), **every unevaluated assertion by its own label**, what is therefore unestablished, and the remedy — counted (4) and ending in exit 2, so `verify-all` reports SKIP (the suite is declared in `DECLARED_SKIPS` with that precondition, which is printed) instead of PASS. The labels are not prose any more: the checks PRINT them and the SKIP prints them from ONE list, `CROSS_DISTRO_CHECKS`, so the words inside the SKIP cannot drift from what actually did not run (the old text, "the two cross-distribution assertions", counted them wrongly and named none of them). That 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 own override (pointed at the selected distribution, and 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 the skip is conditional — with a second distribution present the suite still exits 0, prints no SKIP of this family, and runs all four assertions (each label printed PASS) — and a copy whose `lib/wsl/fence.js` has the **distribution binding removed** must redden the shared-identity assertion, so "they still run" is proven rather than claimed.
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…`).
258
420
 
259
421
  ## License
260
422
 
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  >
7
7
  > **边界定义**:
8
8
  > - ✅ **适合**:插件作者本人或同信任级别操作者,在明确支持的发行版(见「发行版支持矩阵」)与 0.1.7+ 桌面上长期使用。
9
- > - ⚠️ **条件**:confined 模式的信任边界依赖 NO_NEW_PRIVS(debian 系现代 setpriv 满足;不支持的老 setpriv 上围栏可被模型自身的 sudo 授权穿透,见 caveats)。
9
+ > - ⚠️ **条件**:confined 模式的信任边界依赖 NO_NEW_PRIVS(debian 系现代 setpriv 满足;setpriv 不认 `--no-new-privs` 时 confined 模式**根本不运行**——direct runner 在测到 `false` 时拒绝,helper 的降权用同一个标志、同样失败关闭,见 caveats)。
10
10
  > - ❌ **尚不适合**:分发给第三方用户(缺桌面版本门控与发行版矩阵——见生产化路线图与 `docs/DISTRO-SUPPORT.md`)、无人值守高价值环境(subprocess 面不设防为已披露设计;对 harness 上游的两项 API 提案见 `docs/UPSTREAM-PROPOSALS.md`)。
11
11
 
12
12
  ## 桌面更新纪律(强制)
@@ -126,10 +126,10 @@ read-only: tmpfs /tmp → remount,ro,bind /
126
126
  **helper 在启动前就丢掉调用者给的 BASH_ENV,并且不导入环境里的函数。** bash 会在脚本第一行之前 source 调用者给的 `BASH_ENV` 文件——以 root 身份,早于本文件里的每一条控制(PATH 钉子在内,所以钉子关不掉它)。实测(bash 5.2.37):`bash script`、`#!/bin/bash` shebang 经 exec、`bash -c` 都会 source,而 `bash -p script`、`#!/bin/bash -p` shebang、`bash -p -c` 都不会。由此有三处改动。shebang 是 `#!/bin/bash -p`。`-p` 不会把这个变量从环境里删掉,而每个后代都会继承它——降权后的 `bash -lc` 不是特权模式、确实会处理它(实测:以会话用户身份、在围栏内 source 了该文件)——所以 exec 尾巴用 `env -u BASH_ENV` 删除,删在唯一那条所有 root 阶段后代都挂在它下面的调用上。又因为 `-p` 是 shell 自身的属性,围栏体这个独立的 `bash -c` 仍会导入调用者导出的函数:一组配合好的 `mount`/`findmnt`/`mountpoint` 覆盖会让清扫报告“系统已受限”而 `/mnt/c` 保持可写(实测:静默绕过围栏;只覆盖 `mount()` 时只是碰巧在后置条件上失败关闭),因此围栏体也用 `-p` 启动,使这类覆盖彻底失效。`-p` 同时关掉了绕过 PATH 钉子的第二条伪造路径(实测:伪造的 `getent()` 函数让身份闸门接受 `--uid 0 --gid 0`,命令以 uid 0 跑在围栏里;加上 `-p` 后闸门拒绝,exit 2)。严重性:这是围栏之前、第一行之前的无约束 root 代码执行,超出 PATH 钉子关掉的“围栏内 root 访问”(围栏内的 root 也不是只读者,见身份闸门那段)——可达性由 sudoers 决定:**窄**规则并**不**隐含 SETENV,而导出的 `BASH_ENV` 会被 sudo 的 env_reset 剥掉(实测),所以这条路需要命令行赋值形式,而 SETENV 允许它、`ALL` 匹配隐含它;helper 侧的修复因此是无条件的,不依赖部署的 sudoers。`HELPER_VERSION` 保持 v1.2,所以**没重装的旧 helper 仍会被探测选中**——升级插件后必须重装,这次尤其。
127
127
 
128
128
  **身份闸门失败关闭,但它只在没有 SETENV 的部署里成立。** 闸门用 SUDO_USER 判断调用者;空值或未设**曾经意味着跳过检查**,而这个变量是调用者可设的:实测 `sudo -n SUDO_USER= <helper> --uid 0 --gid 0 --home /root --cwd / -- '…'` 与 `sudo -n env -u SUDO_USER` 都让命令以 uid 0 跑在围栏里、`/etc/shadow` 可读。现在空值/未设一律**拒绝**(exit 2;root 自己直接调用请显式传 `SUDO_USER=root`)。但闸门挡不住**伪造**:`sudo SUDO_USER=root <helper> --uid 0 --gid 0 …` 仍被接受——闸门只有这一个身份来源,`SUDO_UID` 走同一条路同样可伪造,交叉校验买不到东西。因此闸门**只在 sudoers 不授予 SETENV 的部署里**才是边界(没有 `ALL` 匹配、也没有命令行赋值形式);授予 SETENV 时它只挡无意误用、不挡伪造,围栏会按**伪造的 uid** 降权——那是 root **写**,不是读原语:只读状态是私有 namespace 里的 per-mount bind remount,伪造的 uid 0 可以 `mount -o remount,rw /` 与 `mount -o remount,rw /mnt/c`(实测两者都成功,且两个文件系统上都有真实写入落地:root-only 路径、以及 /mnt/c 下新建的文件),从而写发行版文件系统与 Windows 文件系统;仍然成立的只有 namespace 与 NO_NEW_PRIVS,文件围栏不在了。
129
- 2. **NO_NEW_PRIVS(自动,无 helper 时的缓解)**:降权时运行时探测 `setpriv --no-new-privs` 支持(debian 系现代 setpriv 满足,实测 `noNewPrivs: true` 已激活)——围栏内 setuid 提权响亮失败。不支持该标志的老 setpriv 上,此边界仍存在——如实记录于 `enforcement: 'partial'` 的 caveats。
129
+ 2. **NO_NEW_PRIVS(自动,无 helper 时的缓解)**:降权时运行时探测 `setpriv --no-new-privs` 支持(debian 系现代 setpriv 满足,实测 `noNewPrivs: true` 已激活)——围栏内 setuid 提权响亮失败。探测是**三态**的:`false`(该 setpriv 不支持)→ 拒绝,给出 `NO_NEW_PRIVS_UNSUPPORTED`;未测到答案(探针没跑成)→ 同样拒绝,给出 `NO_NEW_PRIVS_UNMEASURED`;只有测到 `true` 才以 `--no-new-privs` 降权。所以**不支持该标志的发行版根本不会运行受限命令**,而且装 helper 不是绕过:helper 的降权用的是同一个标志,同样失败关闭(setpriv 对未知选项 exit 1,实测)——修法是升级发行版的 util-linux。`enforcement: 'partial'` 与这一条无关:它对**每一次**受限运行都成立,描述的是 mount namespace 约束不到的部分(/dev、/proc、/sys 与 interop)加上上面那条保留 sudo 的边界。
130
130
  - **findmnt 的 `\xNN` 转义已解码。** `findmnt -r` 会把 TARGET 里的空格/制表/换行/反斜杠编码为 `\x20` 等——修复前清扫按字面转义名 remount(ENOENT 被 `|| true` 吞掉)且后置条件测的是假名,含空格的挂载点在只读模式下保持可写而退出码 97 不触发。现在两处管道都先解码再匹配,并有真实含空格 bind 目标的只读断言回归(verify-confinement)。
131
131
  - **进 namespace 后必须降回原用户。** 经 `sudo` 进入后 euid 是 root,直接用会让工作区里出现 root 属主文件;用 `setpriv --reuid --regid --init-groups` 降回会话用户(有断言覆盖属主)。
132
- - **所有输出被解析的探针一律非登录。** `resolveIdentity` / `detectRunner` / `listLinuxDir` / `checkLinuxPath` / `resolveDistroHome` / `resolveExecutable` / pty 的 python3 探测全部 `loginShell: false`——登录 shell 的 rc 会先于探针命令输出,位置性解析就会把 profile 打印的内容当作 uid/gid/home(模型可写 dotfiles 时等于把 setpriv 的 uid 交给攻击者)。`resolveIdentity` 额外用 `__DSH_IDENTITY__` 哨兵行界定 + 恰好四行校验,解析失败抛**携带探针实际输出**的错误(不再静默 null)。探针超时 60s + 超时后一次透明重试:桌面重启后的首个 wsl.exe 冷启动可以超过短上限。
132
+ - **所有输出被解析的探针一律非登录。** `resolveIdentity` / `detectRunner` / `detectNoNewPrivs` / `listLinuxDir` / `checkLinuxPath` / `resolveDistroHome` / `resolveLoginShell` / `resolveExecutable` / pty 的 python3 探测全部 `loginShell: false`——登录 shell 的 rc 会先于探针命令输出,位置性解析就会把 profile 打印的内容当作 uid/gid/home(模型可写 dotfiles 时等于把 setpriv 的 uid 交给攻击者)。`resolveIdentity` 额外用 `__DSH_IDENTITY__` 哨兵行界定 + 恰好四行校验,解析失败抛**携带探针实际输出**的错误(不再静默 null)。探针超时 60s + 超时后一次透明重试:桌面重启后的首个 wsl.exe 冷启动可以超过短上限。
133
133
  - **wsl.exe 的选项值在 spawn 前过语法校验。** `runWslShell` / `buildWslExecArgv` 顶部对 distro(`DISTRO_NAME`)与 username(`LINUX_USER`)拒绝分隔符字符——exec 路径的安全性不依赖 wsl.exe 外部未文档化的分词规则;checkPath 也在任何 wsl.exe 副作用之前先做 UNC 校验(回归钉在 verify-world)。
134
134
 
135
135
  ## 关键约束(都是实测得出,不是推断)
@@ -135,16 +135,32 @@ export async function resolveIdentity({ distro, username, run }) {
135
135
  * answered yes, i.e. it would have selected a file the model owns.
136
136
  *
137
137
  * The gate therefore requires, before the version is even asked for:
138
- * - the path holds an executable file (the pre-existing 'test -x' — which also
139
- * requires the INVOKING user's execute bit, so a 0700 root:root helper is not
140
- * selected; that is fail-closed and deliberately unchanged here);
138
+ * - a REGULAR FILE at the path ('[ -f ]'), because 'test -x' alone also accepts
139
+ * a directory — measured: a drwxr-xr-x root:root directory satisfied every
140
+ * other test and the ownership predicate answered yes for it. Only the version
141
+ * half refused it, and only because sudo cannot exec a directory;
142
+ * - NOT a SYMLINK ('[ -L ]' refuses; it is not resolved). Which tests resolve a
143
+ * link is not uniform, and that asymmetry is the reason for the explicit test:
144
+ * 'test -x' and '[ -w ]' follow the link, while 'stat -c %u/%a' do NOT follow
145
+ * it. A link is therefore refused today as collateral (stat reports the link's
146
+ * own fixed 0777 mode, which fails the write-bit test) with nothing in the
147
+ * script stating that this is intended. Resolving instead would demand
148
+ * checking BOTH chains — the link's own directory is decisive, since
149
+ * re-pointing it at any root-owned binary gives the caller that binary as
150
+ * root under an argument-wildcarded grant — so refusal stays the choice;
151
+ * - the INVOKING user's execute bit ('test -x'), so a 0700 root:root helper is
152
+ * not selected; that is fail-closed and deliberately unchanged here;
141
153
  * - owner uid 0 ('stat -c %u'), so a session-user-owned file can never be the
142
154
  * grant's target;
143
155
  * - no group or other WRITE bit ('stat -c %a' & 022 == 0). The '-w' test alone
144
156
  * would miss a mode a later group membership could widen; the mode bits alone
145
157
  * would miss an ACL. Both are checked;
146
158
  * - not writable by the invoking user ('[ -w ]', which also sees ACLs);
147
- * - no writable ancestor up to '/'.
159
+ * - no writable ancestor up to '/'. Measured for the resolved case too: with the
160
+ * helper reached through a symlinked ANCESTOR (/opt/.../linkdir ->
161
+ * /home/<user>/...), '[ -w ]' follows that component and answers no, so the
162
+ * ancestor half has no lexical-vs-resolved gap — the walk tests each lexical
163
+ * component, and each test sees through it.
148
164
  *
149
165
  * Deliberately NOT a literal '0:755': a stricter mode must never be refused for
150
166
  * being stricter, and what has to be pinned is replaceability. The '-w' test
@@ -163,6 +179,30 @@ function helperGateScript(helperPath) {
163
179
  return [
164
180
  'f=' + target,
165
181
  'ok=yes',
182
+ // TYPE FIRST, and by an explicit test rather than by 'test -x': a root:root
183
+ // 0755 DIRECTORY satisfies -x, uid 0, no write bits and every ancestor test,
184
+ // so the ownership predicate answered yes for a directory — measured, as
185
+ // zcluo, with /opt/.../dir-helper (drwxr-xr-x root root). Only the version
186
+ // half refused it, and only because sudo cannot exec a directory. The gate
187
+ // claims to require an executable FILE; this is where it measures that.
188
+ '[ -f "$f" ] || ok=no',
189
+ // A symlink is REFUSED, not resolved. The grant names a PATH, so the link
190
+ // and its target are both part of the trust decision: resolving would have
191
+ // to check the link's own directory too (re-pointing it at any other
192
+ // root-owned binary such as /bin/bash is enough to run the caller's argv as
193
+ // root under an argument-wildcarded grant), while the ancestor walk below
194
+ // can only walk one of the two chains. Refusal is simpler, fail-closed, and
195
+ // costs only the hardened runner: the probe falls back to the direct
196
+ // sudo-unshare runner, and the README's install is a real file copy.
197
+ // Today such a path is refused already — but only as collateral, because
198
+ // 'stat' does NOT follow a link and reports the link's own fixed 0777 mode,
199
+ // which fails the write-bit test below. Nothing in the script says so, so
200
+ // any later edit that made the stat calls follow (-L) would silently turn
201
+ // that accident into an accepted, replaceable helper. This makes the
202
+ // refusal the measured intent instead of a side effect.
203
+ '[ -L "$f" ] && ok=no',
204
+ // The invoking user's execute bit (this follows the link, hence the tests
205
+ // above): a 0700 root:root helper is not selected — fail-closed, unchanged.
166
206
  'test -x "$f" || ok=no',
167
207
  'u=$(stat -c %u "$f" 2>/dev/null) || u=',
168
208
  'm=$(stat -c %a "$f" 2>/dev/null) || m=',
@@ -375,10 +415,23 @@ export async function detectNoNewPrivs({ distro, username, run }) {
375
415
  * a MEASURED limitation of that distribution.
376
416
  *
377
417
  * Names the consequence (the retained passwordless sudo grant voids the file
378
- * fence) and both ways out: the hardened helper, whose drop always sets
379
- * NO_NEW_PRIVS, or a newer util-linux.
418
+ * fence) and the ONE remedy that can work: a newer util-linux. The hardened
419
+ * helper is NOT a way out and the text says so, because the helper's drop has
420
+ * no pre-flight for the flag at all: it execs `setpriv --no-new-privs`
421
+ * unconditionally (dsh-wsl-confine.sh), so on a setpriv without the flag setpriv
422
+ * rejects the unknown option and exits 1 — the confined command simply does not
423
+ * run, and that is an ordinary COMMAND failure, not the setup-failure marker
424
+ * that reports an unavailable runner. README.md:129 records the measured exit 1;
425
+ * this text previously contradicted it by recommending the helper as a remedy.
426
+ *
427
+ * The remedy names the PROBEABLE requirement, not a version: the util-linux release
428
+ * that first carried the flag was an unsourced figure in operator-facing text, and
429
+ * the operator's real question is answerable in one command on the machine in front
430
+ * of them — the same `setpriv --help` probe {@link NO_NEW_PRIVS_UNMEASURED} already
431
+ * hands out. A version number cannot answer it (distributions backport), and
432
+ * docs/DISTRO-SUPPORT.md's row for this boundary now states the same criterion.
380
433
  */
381
- export const NO_NEW_PRIVS_UNSUPPORTED = 'wsl-sandbox: 发行版的 setpriv 不支持 --no-new-privs,无法在降权时置位 NO_NEW_PRIVS:会话用户保留的免密 sudo 可借此重新逃出文件围栏。请安装 dsh-wsl-confine helper(它的降权固定置位 NO_NEW_PRIVS),或升级发行版的 util-linux。'
434
+ export const NO_NEW_PRIVS_UNSUPPORTED = 'wsl-sandbox: 发行版的 setpriv 不支持 --no-new-privs,无法在降权时置位 NO_NEW_PRIVS:会话用户保留的免密 sudo 可借此重新逃出文件围栏。安装 dsh-wsl-confine helper 不是绕过:helper 的降权对同一个标志(--no-new-privs)没有预检,直接 exec setpriv --no-new-privs,在缺少该标志的 setpriv 上 setpriv 对未知选项报错并 exit 1、命令不会运行(这是一次普通的命令失败,不是 runner 不可用的 setup 失败标记)。唯一可行的修法是升级发行版的 util-linux——判据是可探测的、不要按版本号判断:在发行版里跑 `setpriv --help 2>&1 | grep -- --no-new-privs`,有输出才说明这个 setpriv 带着该标志。'
382
435
 
383
436
  /**
384
437
  * Refusal text for a NO_NEW_PRIVS probe that did not measure anything.
@@ -387,8 +440,11 @@ export const NO_NEW_PRIVS_UNSUPPORTED = 'wsl-sandbox: 发行版的 setpriv 不
387
440
  * property of the distribution. It says the probe did not run (or answered
388
441
  * neither yes nor no), so the state is UNKNOWN — and an operator can tell the
389
442
  * two apart, which is what makes the refusal a diagnosis instead of a wall.
443
+ * The helper is mentioned as a workaround for THIS probe only, with what it
444
+ * actually does stated: it skips the probe but not the flag, so it is a gamble
445
+ * on a setpriv that carries the flag — not a remedy for one that lacks it.
390
446
  */
391
- export const NO_NEW_PRIVS_UNMEASURED = 'wsl-sandbox: 无法测量发行版的 setpriv 是否支持 --no-new-privs(探针未运行,或没有给出 yes/no 答案):降权是否置位 NO_NEW_PRIVS 未知,因此不运行这条命令。请重试;若反复出现,请安装 dsh-wsl-confine helper(它的降权固定置位 NO_NEW_PRIVS,不使用该探针)。'
447
+ export const NO_NEW_PRIVS_UNMEASURED = 'wsl-sandbox: 无法测量发行版的 setpriv 是否支持 --no-new-privs(探针未运行,或没有给出 yes/no 答案):降权是否置位 NO_NEW_PRIVS 未知,因此不运行这条命令。请重试;若反复出现,先在发行版里手动确认该标志(setpriv --help 2>&1 | grep -- --no-new-privs)。dsh-wsl-confine helper 的降权不走这个探针、固定 exec setpriv --no-new-privs,但用的还是同一个标志:标志缺失时它同样以 exit 1 失败、命令不会运行,因此不能代替升级 util-linux。'
392
448
 
393
449
  /**
394
450
  * The refusal a direct-runner confined command must be stopped with, given the
package/lib/wsl/pty.js CHANGED
@@ -179,7 +179,8 @@ async function requireBridgeRuntime(plan, options) {
179
179
  * Hence a host-side rm -f, which is the only cleanup that survives a SIGKILL.
180
180
  * Best effort and bounded (a distribution that is going down must not hang a
181
181
  * teardown) and idempotent (safe on every path that ends a session). The cost is
182
- * one extra wsl.exe spawn per closed session; the alternative — inferring from
182
+ * one wsl.exe spawn per closed session, memoized in cleanupFifo() so the two end
183
+ * paths cannot each pay it; the alternative — inferring from
183
184
  * the session outcome whether the bridge's finally ran — is not portable: on
184
185
  * Windows a killed process reports no signal, and a non-zero exit code is also
185
186
  * the ordinary shape of a session whose command failed.
@@ -241,6 +242,23 @@ export async function spawnWslTerminal({
241
242
  let size = boundSize(cols, rows, { cols: DEFAULT_COLS, rows: DEFAULT_ROWS })
242
243
  const fifo = `/tmp/dsh-pty-${randomUUID().slice(0, 12)}.fifo`
243
244
  const select = { ...(wslPath !== undefined ? { wslPath } : {}), ...(username !== undefined ? { username } : {}) }
245
+ /**
246
+ * The session's ONE host-side FIFO removal, shared by every path that can end it.
247
+ *
248
+ * Both end paths reach here — the data-done handler (a bridge that exited or
249
+ * was killed) and terminate()/the failed-allocation catch — and each used to
250
+ * call removeFifo itself. Measured with a call counter across a full
251
+ * verify-pty-handle run: EVERY one of the five sessions it drives called
252
+ * removeFifo exactly TWICE, so "one extra wsl.exe spawn per closed session"
253
+ * was wrong by a factor of two, on the killed and failed paths as much as on
254
+ * terminate. Memoizing the promise makes the first caller the owner and every
255
+ * other caller await the same settlement: one rm per session, no end path left
256
+ * uncovered, and a path that throws before its own call still gets the
257
+ * handler's.
258
+ * @returns {Promise<void>} settlement of the single removal.
259
+ */
260
+ let fifoCleanup
261
+ const cleanupFifo = () => (fifoCleanup ??= removeFifo(plan, fifo, select))
244
262
  termLog(`allocate: distro=${plan.distro} linuxCwd=${JSON.stringify(plan.linuxCwd)} windowsCwd=${JSON.stringify(plan.windowsCwd)} argv0=${JSON.stringify(argv[0])} cols=${String(size.cols)} rows=${String(size.rows)} fifo=${fifo}`)
245
263
  await requireBridgeRuntime(plan, select)
246
264
  termLog('allocate: bridge runtime ok')
@@ -338,9 +356,9 @@ export async function spawnWslTerminal({
338
356
  // distribution-side bash, holding the FIFO.
339
357
  control?.terminate()
340
358
  // The bridge is gone, so its own finally either ran or was preempted by a
341
- // kill — and only this side can answer that by looking. Runs on every
342
- // session end (idempotent), which is what covers the killed case.
343
- void removeFifo(plan, fifo, select)
359
+ // kill — and only this side can answer that by looking. Reaches the shared
360
+ // removal on every session end, which is what covers the killed case.
361
+ void cleanupFifo()
344
362
  },
345
363
  (error) => {
346
364
  bridgeDown = true
@@ -351,7 +369,7 @@ export async function spawnWslTerminal({
351
369
  // Same host-side cleanup as the exited-bridge branch above: a FAILED data
352
370
  // process is the case where the bridge's own finally is least likely to
353
371
  // have run.
354
- void removeFifo(plan, fifo, select)
372
+ void cleanupFifo()
355
373
  },
356
374
  )
357
375
 
@@ -466,7 +484,7 @@ export async function spawnWslTerminal({
466
484
  await Promise.allSettled([data.waitForExit?.() ?? waitForHandle(data), waitForHandle(control)])
467
485
  // AFTER both processes have settled, from the host side: the bridge's
468
486
  // own finally lost this race often enough to leave orphans behind.
469
- await removeFifo(plan, fifo, select)
487
+ await cleanupFifo()
470
488
  },
471
489
  }
472
490
  } catch (error) {
@@ -491,7 +509,7 @@ export async function spawnWslTerminal({
491
509
  // A session that never came up can still have created its FIFO (the bridge
492
510
  // makes it before it announces), so the failed-allocation path cleans up too:
493
511
  // rm -f is a no-op when it was never created.
494
- await removeFifo(plan, fifo, select)
512
+ await cleanupFifo()
495
513
  throw error
496
514
  }
497
515
  }
package/lib/wsl/shell.js CHANGED
@@ -235,6 +235,14 @@ export class WslShellExecutor extends ShellExecutor {
235
235
  if (refusal !== null) throw new SandboxUnavailableError(mode, refusal)
236
236
  noNewPrivs = true
237
237
  }
238
+ // What the RESULT reports is the boundary the run actually gets, not the
239
+ // builder input. The helper's own drop sets NO_NEW_PRIVS unconditionally
240
+ // (`setpriv --no-new-privs`, dsh-wsl-confine.sh), so a helper run that
241
+ // reported `noNewPrivs: false` understated its own boundary; the direct
242
+ // runner reaches this line only with a MEASURED flag (the refusal above).
243
+ // Both runners therefore report true, and the field means one thing again:
244
+ // this run's drop carried NO_NEW_PRIVS.
245
+ const noNewPrivsApplied = runner === RUNNER_HELPER || noNewPrivs === true
238
246
  const workspaceLinuxRoot = workspaceRootInLinux(spec.sandboxPolicy?.workspaceRoot, plan.linuxCwd, toLinux)
239
247
  if (workspaceLinuxRoot === null) {
240
248
  throw new SandboxUnavailableError(mode, 'wsl-sandbox: 工作区根目录无法映射到发行版内')
@@ -258,11 +266,15 @@ export class WslShellExecutor extends ShellExecutor {
258
266
  // interop — a confined command may still ask `wsl.exe` to run a Windows
259
267
  // program that writes files; and privilege re-escalation — the session
260
268
  // user keeps the passwordless sudo grant the confinement runner itself
261
- // requires. When the distro's setpriv supports NO_NEW_PRIVS the drop
262
- // sets it and sudo inside the fence fails loudly; on distros without
263
- // that flag a deliberately non-compliant command can void the file
264
- // fence via retained sudo (see README). Reporting `full` here was wrong.
265
- sandbox: { mode, denied: false, enforcement: 'partial', noNewPrivs },
269
+ // requires. That route is closed on BOTH runners, which is why no
270
+ // reachable path here runs a confined command without the flag: the direct
271
+ // runner REFUSES unless --no-new-privs was MEASURED present (so its drop
272
+ // always carries it and sudo inside the fence fails loudly), and the
273
+ // helper's own drop sets it unconditionally. A distribution whose setpriv
274
+ // lacks the flag therefore never runs a direct-runner command at all —
275
+ // the refusal above is what used to be described here as "the fence can be
276
+ // voided via retained sudo". Reporting `full` here was wrong.
277
+ sandbox: { mode, denied: false, enforcement: 'partial', noNewPrivs: noNewPrivsApplied },
266
278
  }
267
279
  }
268
280
 
@@ -29,10 +29,19 @@ import z from '@deepseek-ai/schemastery'
29
29
  import { requireLocalSubprocess } from './host-refs.js'
30
30
  import { shellQuote } from './paths.js'
31
31
  import { spawnWslTerminal, termLog } from './pty.js'
32
- import { WSL_CHILD_ENV, buildWslExecArgv, planWsl, resolveLoginShell, runWslShell, withWslEnvFlags } from './world.js'
32
+ import { LoginShellUnresolvedError, WSL_CHILD_ENV, buildWslExecArgv, planWsl, resolveLoginShell, runWslShell, withWslEnvFlags } from './world.js'
33
33
 
34
- /** Executable lookup is a short probe, not a run. */
35
- const LOOKUP_TIMEOUT_MS = 15_000
34
+ /**
35
+ * The executable lookup's ceiling, on the documented probe policy.
36
+ *
37
+ * Was 15s and the shortest ceiling in the plugin. README.md:132 / README.en.md:132 name
38
+ * `resolveExecutable` in the same sentence as 探针超时 60s + 超时后一次透明重试, and it is an
39
+ * output-parsed probe like the siblings that sentence lists (the comment on the probe below
40
+ * says so), so it gets the same 60s as `listLinuxDir`/`checkLinuxPath`/`resolveDistroHome`
41
+ * and the same single repeat. The ceiling is not a latency budget: it is the point past which
42
+ * a probe that produced NO answer is reported instead of being believed.
43
+ */
44
+ const LOOKUP_TIMEOUT_MS = 60_000
36
45
 
37
46
  /** Windows shells whose names the terminal controller may probe inside the distribution. */
38
47
  const WINDOWS_SHELLS = new Set(['powershell', 'pwsh', 'cmd'])
@@ -125,7 +134,7 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
125
134
  const probe = command.startsWith('/')
126
135
  ? `[ -x ${shellQuote(command)} ] && printf '%s\\n' ${shellQuote(command)}`
127
136
  : `command -v ${shellQuote(command)}`
128
- const result = await runWslShell({
137
+ const request = {
129
138
  distro: plan.distro,
130
139
  linuxCwd: plan.linuxCwd,
131
140
  command: probe,
@@ -140,7 +149,18 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
140
149
  ...env !== undefined ? { env } : {},
141
150
  timeoutMs: LOOKUP_TIMEOUT_MS,
142
151
  ...signal !== undefined ? { signal } : {},
143
- })
152
+ }
153
+ const ask = () => runWslShell(request)
154
+ // The SAME ruling as `resolveIdentity`/`detectRunner`/`detectNoNewPrivs` in
155
+ // confinement.js, in the same shape (one repeat of the SAME probe, triggered by
156
+ // `timedOut` alone — not a second convention): a timeout is NOT an answer, and the
157
+ // guard below reads an empty answer as "the distribution has no such executable".
158
+ // A retry cannot launder a miss: a command that is genuinely absent answers in one
159
+ // attempt (a fast, non-zero `command -v`), which is why the trigger is the timeout
160
+ // and not "any bad answer". An abort is not a timeout either — `runWslShell` leaves
161
+ // `timedOut` false for it — so a cancelled lookup is never repeated.
162
+ let result = await ask()
163
+ if (result.timedOut === true) result = await ask()
144
164
  const found = result.stdout.split('\n').map((line) => line.trim()).find((line) => line.length > 0)
145
165
  if (found === undefined) {
146
166
  // The terminal controller resolves the deployment's configured default
@@ -149,7 +169,33 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
149
169
  // shell. Absent any Windows shell, the login shell is the honest answer.
150
170
  const base = String(command).split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
151
171
  if (WINDOWS_SHELLS.has(base)) {
152
- return resolveLoginShell(plan.distro, this.config.username, { wslPath: this.config.wslPath })
172
+ try {
173
+ return await resolveLoginShell(plan.distro, this.config.username, { wslPath: this.config.wslPath })
174
+ } catch (error) {
175
+ // The translation is what makes the failure SKIPPABLE, and the reason
176
+ // is not lost by it: `error.message` is still the login-shell probe's
177
+ // own evidence (探针超时, the distribution, its stderr) and `cause`
178
+ // keeps the class it came from. Same shape as subprocess-ssh's
179
+ // resolveExecutable, which rethrows this class with `cause` for a
180
+ // coded lookup failure rather than hand-rolling a second convention.
181
+ //
182
+ // Why the class and not a plain Error: the terminal controller
183
+ // catches exactly `SubprocessExecutableNotFoundError` to SKIP a
184
+ // candidate and continue down its shell list (zsh, fish, ... are
185
+ // commonly absent from a distribution). A plain Error rejected the
186
+ // whole discovery instead, so the terminal shell dropdown failed on
187
+ // every distro — and the controller's own candidates include three
188
+ // Windows shell names (pwsh/powershell/cmd), so ONE stall that
189
+ // survived the login-shell probe's repeat reached this line three
190
+ // times in a single discovery.
191
+ if (!(error instanceof LoginShellUnresolvedError)) throw error
192
+ // The skip is silent by design in the controller (absent shells are
193
+ // ordinary), so the stall is disclosed here instead: without this line
194
+ // a persistent stall would quietly shrink the shell list and leave no
195
+ // trace anywhere.
196
+ termLog(`resolveExecutable: 登录 shell 探针未给出答案,跳过候选 ${command}:${error.message}`)
197
+ throw new SubprocessExecutableNotFoundError(error.message, { cause: error })
198
+ }
153
199
  }
154
200
  // The structured class, not a plain Error: the terminal controller
155
201
  // catches exactly this type to SKIP a candidate and continue down its
@@ -216,6 +262,13 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
216
262
  // that dialect-translates to the session user's login shell — a lone
217
263
  // '-NoLogo' arg would kill bash, so the whole argv is translated.
218
264
  const base0 = String(spec.argv?.[0] ?? '').split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
265
+ // Deliberately NOT translated into SubprocessExecutableNotFoundError the
266
+ // way the candidate lookup above is. There is no candidate list here to
267
+ // skip down — this spec named exactly ONE program — so the translation
268
+ // would buy nothing and would blur a transport failure ("the probe did not
269
+ // answer") into "this distribution has no powershell". The failure stays
270
+ // loud, typed, and carrying the probe's evidence; substituting a shell the
271
+ // probe never measured is the silent wrong answer round 4 removed.
219
272
  const terminalArgv = WINDOWS_SHELLS.has(base0)
220
273
  ? [await resolveLoginShell(plan.distro, this.config.username, { wslPath: this.config.wslPath })]
221
274
  : spec.argv
package/lib/wsl/world.js CHANGED
@@ -514,14 +514,49 @@ export function runWslShell({ distro, linuxCwd, command, username, loginShell =
514
514
  })
515
515
  }
516
516
 
517
+ /** The documented probe ceiling: 60s, because a cold VM's first spawn can exceed 30s. */
518
+ const PROBE_TIMEOUT_MS = 60_000
519
+
520
+ /**
521
+ * Ceiling for one parsed-output probe, and the retry that goes with it.
522
+ *
523
+ * README.md states this policy for exactly these probes — 探针超时 60s + 超时后一次透明
524
+ * 重试:桌面重启后的首个 wsl.exe 冷启动可以超过短上限 — and it names `listLinuxDir`,
525
+ * `checkLinuxPath` and `resolveDistroHome` in the same sentence as the probes that
526
+ * already had it (`resolveIdentity`, `detectRunner`, the NO_NEW_PRIVS probe, the PTY's
527
+ * python3 probe). These three were the only parsed-output probes still on a 30s ceiling
528
+ * with no retry, so a single wsl.exe stall longer than 30s threw straight out of
529
+ * `checkLinuxPath` — and the stall is a property of the machine, not of the path: the
530
+ * distribution shares one VM with every other one, and measured while that VM ran at
531
+ * load ~11 with its swap 95% full, this probe answered in ~300ms and then timed out at
532
+ * 30.4s and 30.5s on two consecutive calls. The retry is the same ruling the identity
533
+ * probe follows: repeat the SAME probe once, immediately, and let a persistent stall
534
+ * still fail loudly with the second attempt's evidence.
535
+ *
536
+ * Retrying is safe here because all three probes are READS: the listing protocol frames
537
+ * its own output and the check prints one of three tokens, so a repeat cannot duplicate
538
+ * a side effect. The trigger is deliberately `timedOut` alone and not "any bad answer":
539
+ * a non-zero exit from the listing script is the ordinary answer for a directory that is
540
+ * not there, and doubling the latency of that case would slow the interactive picker for
541
+ * no measured benefit.
542
+ * @param {(request: object) => Promise<object>} run - the probe runner.
543
+ * @param {object} request - the probe, without its ceiling.
544
+ * @returns {Promise<object>} the first conclusive attempt, or the second after a timeout.
545
+ */
546
+ async function probeWithRetry(run, request) {
547
+ const first = await run({ ...request, timeoutMs: PROBE_TIMEOUT_MS })
548
+ if (first.timedOut !== true) return first
549
+ return await run({ ...request, timeoutMs: PROBE_TIMEOUT_MS })
550
+ }
551
+
517
552
  /**
518
553
  * List one directory inside a distribution.
519
554
  * @param {string} distro - distribution name.
520
555
  * @param {string} linuxPath - absolute Linux directory.
521
- * @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
556
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution — the same seam `resolveIdentity`/`detectRunner` expose).
522
557
  * @returns {Promise<{ path: string, parent: string | null, entries: Array<{ name: string, kind: 'directory' | 'file' }> }>} the listing.
523
558
  */
524
- export async function listLinuxDir(distro, linuxPath, { wslPath } = {}) {
559
+ export async function listLinuxDir(distro, linuxPath, { wslPath, run = runWslShell } = {}) {
525
560
  const path = linuxPath.startsWith('/') ? linuxPath : `/${linuxPath}`
526
561
  // Entries are NUL-terminated so a filename containing a newline survives the
527
562
  // round trip; `..?*` covers names like `..keep` that `.[!.]*` misses.
@@ -532,7 +567,7 @@ export async function listLinuxDir(distro, linuxPath, { wslPath } = {}) {
532
567
  // UTF-16LE — with one-character entry names every NUL lands on exactly the
533
568
  // byte positions UTF-16LE uses — and guessing wrong returned [] for a
534
569
  // directory that is not empty.
535
- const result = await runWslShell({ distro, linuxCwd: '/', command: script, loginShell: false, timeoutMs: 30_000, raw: true, wslPath })
570
+ const result = await probeWithRetry(run, { distro, linuxCwd: '/', command: script, loginShell: false, raw: true, wslPath })
536
571
  if (result.exitCode !== 0) {
537
572
  throw new Error(`无法列出 ${path}:${result.stderr.toString('utf8').trim() || `退出码 ${String(result.exitCode)}`}`)
538
573
  }
@@ -553,13 +588,13 @@ export async function listLinuxDir(distro, linuxPath, { wslPath } = {}) {
553
588
  * Check whether one Linux path exists.
554
589
  * @param {string} distro - distribution name.
555
590
  * @param {string} linuxPath - absolute Linux path.
556
- * @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
591
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution).
557
592
  * @returns {Promise<{ exists: boolean, isDirectory: boolean }>} existence facts.
558
593
  */
559
- export async function checkLinuxPath(distro, linuxPath, { wslPath } = {}) {
594
+ export async function checkLinuxPath(distro, linuxPath, { wslPath, run = runWslShell } = {}) {
560
595
  const script = `if [ -d ${shellQuote(linuxPath)} ]; then echo dir; `
561
596
  + `elif [ -e ${shellQuote(linuxPath)} ]; then echo other; else echo missing; fi`
562
- const result = await runWslShell({ distro, linuxCwd: '/', command: script, loginShell: false, timeoutMs: 30_000, wslPath })
597
+ const result = await probeWithRetry(run, { distro, linuxCwd: '/', command: script, loginShell: false, wslPath })
563
598
  // A probe that could not RUN is not "the path is missing". When the script runs
564
599
  // at all it exits 0 and prints exactly one of its three answers, so a non-zero
565
600
  // code, a timeout, or an unreadable answer means the DISTRIBUTION failed
@@ -609,16 +644,16 @@ export function probeEvidence(entry) {
609
644
  * exactly one path.
610
645
  * @param {string} distro - distribution name.
611
646
  * @param {string} [username] - user to resolve; absent resolves the default user.
612
- * @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
647
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution).
613
648
  * @returns {Promise<{ user: string, home: string }>} the user and their home.
614
649
  * @throws Error when the user or a usable home cannot be resolved.
615
650
  */
616
- export async function resolveDistroHome(distro, username, { wslPath } = {}) {
651
+ export async function resolveDistroHome(distro, username, { wslPath, run = runWslShell } = {}) {
617
652
  let user = typeof username === 'string' ? username.trim() : ''
618
653
  if (user === '') {
619
654
  // Non-login + trimmed on purpose: the parsed answer must be provably this
620
655
  // probe's output (the login name), never profile scripts'.
621
- const who = await runWslShell({ distro, linuxCwd: '/', command: 'id -un', loginShell: false, timeoutMs: 30_000, wslPath })
656
+ const who = await probeWithRetry(run, { distro, linuxCwd: '/', command: 'id -un', loginShell: false, wslPath })
622
657
  user = who.stdout.trim()
623
658
  if (user === '' || user.includes('\n')) {
624
659
  throw new Error(`无法确定发行版 ${distro} 的默认用户:${who.stderr.trim() || `退出码 ${String(who.exitCode)}`}`)
@@ -629,12 +664,11 @@ export async function resolveDistroHome(distro, username, { wslPath } = {}) {
629
664
  // output, not by the exit code. Exactly one passwd line is the only
630
665
  // unambiguous answer: a name getent misparses can dump the whole database,
631
666
  // and a multi-line "home" would only fail later and confusingly at listDir.
632
- const entry = await runWslShell({
667
+ const entry = await probeWithRetry(run, {
633
668
  distro,
634
669
  linuxCwd: '/',
635
670
  command: `getent passwd ${shellQuote(user)} | cut -d: -f6`,
636
671
  loginShell: false,
637
- timeoutMs: 30_000,
638
672
  wslPath,
639
673
  })
640
674
  const lines = entry.stdout.trim().split('\n')
@@ -677,16 +711,80 @@ export async function defaultWorkspaceUnc({ wslPath } = {}) {
677
711
  return { distro, linuxPath: home, uncPath: joinWslUnc(distro, home) }
678
712
  }
679
713
 
714
+ /**
715
+ * The login-shell probe did not answer, so no login shell could be determined.
716
+ *
717
+ * A structured class, not a plain Error, because the two failures are not the
718
+ * same failure to the CALLER. The round-4 defect was a VALUE that looked like an
719
+ * answer; this is the other half of that ruling — the value is gone, but a plain
720
+ * Error cannot be told from a failure of the caller's own. `subprocess.js` is
721
+ * the caller that has to act: `resolveExecutable` turns this class into
722
+ * `SubprocessExecutableNotFoundError`, which the terminal controller catches to
723
+ * SKIP a candidate and continue down its shell list (zsh, fish, ... are commonly
724
+ * absent from a distribution), and `spawnTerminal` reports it as the failure of
725
+ * the one program it was asked to run. The controller's default candidates
726
+ * include three Windows shell names (`pwsh`, `powershell`, `cmd` —
727
+ * packages/api/terminal-controller/src/index.ts shellCandidates), and EVERY one of
728
+ * them asks this question, so a stall that survived this probe's own repeat
729
+ * reached the candidate loop three times: as a plain Error the first one aborted
730
+ * the whole discovery, and the shell dropdown failed on every distro instead of
731
+ * losing three entries from it.
732
+ */
733
+ export class LoginShellUnresolvedError extends Error {
734
+ /**
735
+ * @param {string} message - the probe's own evidence, never a guess about the shell.
736
+ */
737
+ constructor(message) {
738
+ super(message)
739
+ this.name = 'LoginShellUnresolvedError'
740
+ }
741
+ }
742
+
680
743
  /**
681
744
  * The session user's login shell inside the distribution — the Linux
682
745
  * equivalent of a deployment-configured Windows terminal shell. Read from the
683
- * user database (`getent`, field 7), falling back to `/bin/bash`.
746
+ * user database (`getent`, field 7), falling back to `/bin/bash` only when the
747
+ * probe RAN and `getent` itself ANSWERED with field 7 EMPTY (passwd(5)'s "no shell
748
+ * configured": the absence of a value, not a value to overrule). Anything the probe
749
+ * printed is returned as measured, path-shaped or not — `nologin` is a deliberate
750
+ * "this account has no interactive shell" marker, and both callers SPAWN this value,
751
+ * so replacing it with a shell the distribution never measured is the silent wrong
752
+ * answer `spawnTerminal` refuses by name. A probe that did not run never reaches
753
+ * this fallback; it throws.
754
+ *
755
+ * `getent`'s own status is what makes that answer provable, and the command
756
+ * carries it (`entry=$(getent passwd …) || exit $?`). The obvious spelling does
757
+ * not: `getent passwd … | cut -d: -f7` exits with `cut`'s status and `cut`
758
+ * exits 0 on empty input, so a `getent` that could not run was read as "this
759
+ * user has no login shell". Measured in a distribution with `getent` shadowed by
760
+ * a function returning 127: the masked pipeline exited 0 with empty output and
761
+ * the fallback answered `/bin/bash` — the silent-wrong-answer class this probe
762
+ * exists to refuse, one layer below the stall, which is why it is refused rather
763
+ * than documented. `resolveDistroHome` already rules the same way for the same
764
+ * masking (see the comment on its own `getent` probe): the pipeline's exit code
765
+ * proves nothing, so the answer is detected by its OUTPUT. A user the
766
+ * distribution does not have answers exit 2 and is refused too: that is not an
767
+ * empty field, it is no answer.
768
+ *
769
+ * A probe that did not complete is NOT the empty-field case either. It used to
770
+ * return `/bin/bash` like a real answer, so a stalled relay — the machine
771
+ * transient README.md's probe policy exists for — silently pinned the session's
772
+ * shell with a value the caller could not tell from the distribution's own. The
773
+ * ceiling and the repeat are `probeWithRetry`'s (探针超时 60s + 超时后一次透明重试),
774
+ * the same ruling `listLinuxDir`/`checkLinuxPath`/`resolveDistroHome` follow, and a
775
+ * second stall fails with {@link LoginShellUnresolvedError} carrying the probe's
776
+ * own evidence instead of answering.
684
777
  * @param {string} distro - distribution name.
685
778
  * @param {string} [username] - Linux user; omitted uses the distribution default.
686
- * @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
779
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution — the same seam the sibling probes expose).
687
780
  * @returns {Promise<string>} the login shell path.
781
+ * @throws {LoginShellUnresolvedError} when the probe timed out twice, could not
782
+ * run, or `getent` did not answer. The one value returned without the probe (an
783
+ * EMPTY field 7) is the documented fallback and is deliberately NOT refused;
784
+ * every other returned value is one `getent` printed — including a marker such
785
+ * as `nologin`, which is an answer and not a missing value.
688
786
  */
689
- export async function resolveLoginShell(distro, username, { wslPath } = {}) {
787
+ export async function resolveLoginShell(distro, username, { wslPath, run = runWslShell } = {}) {
690
788
  // The username must reach BOTH layers: `-u` runs the probe as that user, and
691
789
  // the getent query names that user — `$(id -un)` alone would always answer
692
790
  // for the distribution DEFAULT user even when the session is pinned to
@@ -696,15 +794,33 @@ export async function resolveLoginShell(distro, username, { wslPath } = {}) {
696
794
  // safety property that two layers each assume the OTHER one enforces is one
697
795
  // edit away from disappearing. resolveDistroHome already quotes its user.
698
796
  const who = named ? shellQuote(username) : '"$(id -un)"'
699
- const shell = await runWslShell({
797
+ const shell = await probeWithRetry(run, {
700
798
  distro,
701
799
  linuxCwd: '/',
702
- command: `getent passwd ${who} | cut -d: -f7`,
800
+ // `getent`'s OWN status reaches the caller: the pipeline's status belongs to
801
+ // `cut`, which exits 0 on empty input, so the mask made "getent could not run"
802
+ // indistinguishable from "field 7 is empty" — and the fallback below converts
803
+ // the first into a /bin/bash the caller cannot tell from a measured answer.
804
+ command: `entry=$(getent passwd ${who}) || exit $?; printf '%s\\n' "$entry" | cut -d: -f7`,
703
805
  loginShell: false,
704
806
  ...(named ? { username } : {}),
705
- timeoutMs: 60_000,
706
807
  wslPath,
707
808
  })
809
+ // A probe that did not COMPLETE is not an answer, even when its truncated stream
810
+ // happens to look path-shaped: a killed process is cut mid-write, and a truncated
811
+ // '/bin/bash' still starts with '/'. `checkLinuxPath` refuses a timed-out probe for
812
+ // the same reason. Only a probe that ran and exited 0 may say "no login shell".
813
+ if (shell.timedOut === true || shell.exitCode !== 0) {
814
+ const stderr = typeof shell.stderr === 'string' ? shell.stderr.trim() : ''
815
+ throw new LoginShellUnresolvedError(`无法确定发行版 ${distro} 的登录 shell:${stderr || probeEvidence(shell)}`)
816
+ }
708
817
  const resolved = shell.stdout.trim().split('\n')[0]?.trim() ?? ''
709
- return resolved.startsWith('/') ? resolved : '/bin/bash'
818
+ // EMPTY is the fallback; an ANSWERED value is returned as measured, so the test is
819
+ // deliberately `=== ''` and not `startsWith('/')`. A non-path answer such as
820
+ // `nologin` is the distribution's own ruling that this account has no interactive
821
+ // shell, and both callers SPAWN the value (subprocess.js hands it back as the
822
+ // resolved executable, and uses it as spawnTerminal's whole argv), so swapping in an
823
+ // unmeasured /bin/bash would open exactly the shell the account's passwd entry
824
+ // refuses — a value the caller cannot tell from one the probe measured.
825
+ return resolved === '' ? '/bin/bash' : resolved
710
826
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "exports": {