dsh-wsl-desktop 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -2,137 +2,176 @@
2
2
 
3
3
  English | [简体中文](README.md)
4
4
 
5
- Turns a WSL distribution into a **first-class execution world** inside DSH Desktop: a workspace can live inside the distro, and shell, file tools, subprocess and terminal then all run inside the distro — while Windows workspaces in the same instance keep using the host's own sandbox backends. The two coexist.
5
+ ## What this is
6
+
7
+ A plugin for DSH Desktop. It turns your WSL distribution into a place where work actually happens:
8
+ put a workspace inside the distro and the AI's shell commands, file reads and writes, searches and
9
+ terminals all run inside the distribution instead of on Windows.
10
+
11
+ ## Why you would want it
12
+
13
+ The plugin gives every WSL session a "preset" — the set of tools a session gets when it starts. That
14
+ preset points the shell, file reading and writing, search and terminal at the distribution. So:
15
+
16
+ - **Your Linux tools really work.** With the workspace inside the distro, the AI uses the distro's
17
+ own `bash`, `grep`, `find`, interpreters and packages.
18
+ - **Calls stop being strangers to each other.** The model's `bash` is a persistent shell, so the
19
+ directory and environment variables left by one call are still visible to the next.
20
+ - **Two kinds of workspace can be open at once.** In the same window, Windows workspaces keep
21
+ running on the host's own sandbox backends while WSL workspaces run inside the distro, without
22
+ interfering with each other.
23
+ - **You can still reach the Windows side.** From inside the distro you can invoke host commands; WSL
24
+ calls this feature interop, and it is on by default.
25
+
26
+ ## What you need first
27
+
28
+ - **WSL2.** Not WSL1.
29
+ - **A supported distribution.** Debian, Ubuntu, Fedora/RHEL and Arch are measured and pass; Alpine
30
+ works once `bash` and `util-linux-misc` are installed. The list and each distro's install commands
31
+ are in the [distro support matrix](docs/DISTRO-SUPPORT.md).
32
+ - **DSH Desktop 0.1.7 or newer.** An older host rejects the plugin at activation.
33
+ - **Passwordless sudo for the session user, plus `bash` and `python3`.** This is the one people trip
34
+ on. Confined mode builds a mount namespace — a Linux mechanism that gives a process its own view of
35
+ the mount table — and makes the file system read-only inside it. The WSL kernel refuses bind mounts
36
+ inside a user namespace, so this needs `sudo -n`. Without passwordless sudo, confined mode **fails
37
+ explicitly** instead of falling back to running bare.
38
+ - **A helper is recommended.** The helper is a small script installed inside the distro,
39
+ `dsh-wsl-confine`, that closes the "retained sudo grant" boundary. Install commands are in
40
+ [Confinement](docs/CONFINEMENT.md). It is optional, but recommended.
41
+
42
+ ## How to install it
43
+
44
+ **Plugin market / plugin manager (recommended)**: install `dsh-wsl-desktop@<version>`. The desktop
45
+ wires the package into pnpm and mounts its bundle patch — the configuration that hooks a plugin into
46
+ the desktop — automatically; restart and it works.
47
+
48
+ **CLI equivalent**:
6
49
 
7
- > **Production status: usable in production under controlled single-machine operation** (see the scope boundaries below). Requires **DSH Desktop 0.1.7+** (presets are registered at runtime rather than loaded from directories; an outdated host explicitly rejects at activation). Core capabilities have passed live acceptance; security audits run-1 (5 candidates, closed loop) and run-2 (4 confirmed fixed + 1 rejected, converted to hardening) are complete, with reports in `security-audit-skill/dsh-wsl-desktop/run-{1,2}/`. Per-item evidence strength is annotated in the capability table below.
8
- >
9
- > **Scope boundaries**:
10
- > - ✅ **Suitable**: the plugin author themselves or operators of the same trust level, for long-term use on explicitly supported distros (see the [distro support matrix](docs/DISTRO-SUPPORT.md)) and a 0.1.7+ desktop.
11
- > - ⚠️ **Conditional**: the confined-mode trust boundary depends on NO_NEW_PRIVS (modern debian-family setpriv satisfies it; where setpriv has no `--no-new-privs`, confined mode does NOT run at all — the direct runner refuses once it measures `false`, and the helper's drop passes the same flag and fails closed, see [Security and trust boundaries](SECURITY.md)).
12
- > - ❌ **Not yet suitable**: distribution to third-party users (missing desktop version gating and distro matrix), or unattended high-value environments (the unconfined subprocess surface is a disclosed design; the two API proposals to the harness upstream are in [docs/UPSTREAM-PROPOSALS.md](docs/UPSTREAM-PROPOSALS.md)).
13
-
14
- ## Contents
15
-
16
- - **This page**: [Target capabilities](#target-capabilities) · [Install / Update / Uninstall](#install--update--uninstall) · [Quick start](#quick-start) · [Desktop update discipline](#desktop-update-discipline-mandatory) · [Security and trust boundaries](#security-and-trust-boundaries) · [Known limitations](#known-limitations) · [Verification](#verification) · [Development](#development) · [Release](#release)
17
- - **In-depth material**: [Security and trust boundaries](SECURITY.md) · [Architecture and the execution world](docs/ARCHITECTURE.en.md) · [Confinement (Linux side)](docs/CONFINEMENT.en.md) · [The fs fence](docs/FS-FENCE.en.md) · [Terminal (PTY bridge)](docs/PTY-BRIDGE.en.md) · [WSL file watching](docs/WATCH.en.md) · [Verification and the SKIP ruling](docs/VERIFICATION.en.md) · [Engineering notes](docs/ENGINEERING-NOTES.en.md) · [Distro support matrix](docs/DISTRO-SUPPORT.md) · [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) · [Architecture and flow diagrams](docs/diagrams.md)
18
-
19
- > The bilingual documents linked above (their file names carry an `.en` marker) are enforced as pairs rather than trusted. The three older documents — the distro support matrix, the upstream proposals and the diagram index — are **Chinese-only**, because no English text for them exists: translating them is a separate piece of work, not a rename.
20
-
21
- ## Target capabilities
22
-
23
- **Honest state after live acceptance** (`verify-post-restart.mjs`, latest real run: **58 PASS / 0 FAIL**, tail `NO AUTOMATED FAILURES; 3 manual step(s) pending`, `EXIT=0` — including the two live search rows and the persistent-shell rows; **3 browser-only manual steps remain**, listed in [The persistent shell](docs/PERSISTENT-SHELL.en.md#manual-verification-checklist-browser-only-not-automatable) — plus 20 offline suites; each item below notes its evidence strength):
24
-
25
- | # | Capability | Status |
26
- |---|---|---|
27
- | ① | The workspace can pick a Linux directory inside a WSL distro | Dialog and host calls implemented and live-verified (listDir/checkPath/resolveHome/workspace registration and cleanup); **in-browser interaction manually confirmed by the operator** (gating flow, directory browsing, and terminal panel all normal) |
28
- | ② | shell / file tools / subprocess / terminal inside that workspace work in WSL, and host commands can be invoked from WSL | **Live acceptance PASS**: binding (the create request names the preset), shell inside the distro, fs tools addressing Linux paths, out-of-bounds write rejection, subprocess POSIX environment, bash tool, tool-layer constraints; terminal transport covered by the PTY suite. **The model's `bash` is a persistent PTY shell inside the distribution, keeping cwd and environment variables across calls** (live-verified: two calls read back the same state; the gap list is in [The persistent shell](docs/PERSISTENT-SHELL.en.md)) |
29
- | ③ | Windows and WSL workspaces coexist in the same instance | **Live acceptance PASS**: Windows workspaces stay on the host preset (PowerShell available, no bash); WSL sessions run the confined realm in parallel |
30
- | ④ | Linux-side sandbox constraints on the WSL side | **Shell side fixed and live-verified**: at runtime, all `rw` mounts are enumerated and remounted read-only one by one (observed `/mnt/c`, `/dev/shm`, `/run/user/<uid>` going WRITABLE → READONLY); any failure rejects (exit code 97 preserved); `enforcement` honestly reports `partial`. **fs-side fence implemented and live-verified** (out-of-bounds write rejected, PASS — see [The fs tool fence](docs/FS-FENCE.md)) |
31
-
32
- **Which capabilities are qualified**: the evidence strength is the wording of the status column above. The terminal has no `stdio.control` — it is stated under "Known limitations" rather than left out. The persistent shell's gaps are stated there too, with the authoritative list in [The persistent shell](docs/PERSISTENT-SHELL.en.md).
33
-
34
- ## Install / Update / Uninstall
35
-
36
- **User install (production channel)**: this plugin ships as an npm package, shaped like mature plugins such as dsh-better-sidebar (an exact `files` manifest + `cordis.patch.yml` bundle patch + `dsh.client.inject` client wiring + `manifestVersion`).
37
-
38
- - Plugin market / plugin manager: install `dsh-wsl-desktop@<version>` — the desktop automatically completes the pnpm wiring and the bundle patch mount; restart and it works.
39
- - CLI equivalent: `plugin_manager install_bundle dsh-wsl-desktop@<version>`.
40
- - **Install prerequisites**: WSL2 + the target distro meeting the [support matrix](docs/DISTRO-SUPPORT.md) — NOPASSWD sudo for the session user, bash, python3; installing the dsh-wsl-confine helper to close the fence boundary is recommended (install commands in [docs/CONFINEMENT.md](docs/CONFINEMENT.md)).
41
- - **Update**: installing a new version number is enough; after a desktop major update, run `verify-post-restart.mjs` first per "Desktop update discipline".
42
- - **Uninstall**: uninstalling via the plugin manager also withdraws the wsl-* presets registered by this plugin (guaranteed by the disposer lifecycle).
43
-
44
- > **Version correspondence**: the current `0.3.x` line requires DSH Desktop **0.1.7+**. See "Release" for what the semantic version means — `minor` is precisely "adapted to a new desktop major", so **when the desktop major changes, check here first for the matching minor**.
45
-
46
- ## Quick start
47
-
48
- In the sidebar workspace title bar, to the right of the built-in "+" (add workspace), there is an extra **W** button. It opens this plugin's workspace dialog; the order is: pick a distro → enter the user to enter that distro with (**leave empty = the distro's default user**) → browse or type a Linux directory → create and open a session.
49
-
50
- Once inside browse mode the path lands directly in that user's home directory (the host resolves the user database inside the distro with `getent passwd`). If the entered user does not exist, the error is shown in place in the popup and browse mode is not entered. Switching distros — **including re-clicking the currently selected one** — re-prompts for the user, prefilled with the previously confirmed name. While a session creation is in flight, distro buttons are temporarily unclickable.
51
-
52
- To be precise about the boundary: this step only decides "whose home gets browsed", **not the session identity** — the session always executes inside the distro as the distro's default user (`username` is plugin configuration and is not passed along with session creation).
53
-
54
- In the sidebar workspace list, **a workspace whose path is under a WSL UNC root shows a ☁️ icon instead of the folder**. "+" keeps the deployment's own behavior (the Electron directory picker on Desktop); this plugin does not override it.
55
-
56
- Why the UI and its DOM layer work this way (measured conclusions such as "hiding must use `display`, never `remove()`" and "never use a timer as a fallback") is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
57
-
58
- ## Desktop update discipline (mandatory)
59
-
60
- After every DSH Desktop update, the plugin may break because of harness internal API changes (0.1.6→0.1.7 broke four places at once). **Discipline: update the desktop → run `node scripts/verify-post-restart.mjs` → only keep going when everything is green; any red gets fixed before anything else.** That suite covers preset registration, the execution world, session binding, Windows isolation, and dialog contracts; breakage from a new host shows up on these assertions instead of silently degrading into the host world.
61
-
62
- ## Security and trust boundaries
63
-
64
- - **Host side**: Windows workspaces keep running in DSH's own sandbox backends; this plugin does not replace them.
65
- - **Shell side inside the distro**: `/` is made read-only inside a mount namespace, leaving only the workspace and a private `/tmp` writable; **a fence that cannot be established refuses to execute** — it never runs bare. Mechanism and measured preconditions: [docs/CONFINEMENT.md](docs/CONFINEMENT.md).
66
- - **fs side inside the distro**: `WslFileSystem` carries its own fence, both mutation entries go through `checkedTarget`, and an out-of-bounds target is rejected with structured `FS_SANDBOX_DENIED`. See [The fs tool fence](docs/FS-FENCE.md).
67
-
68
- **Disclosed unconfined surface**: the host subprocess surface being unconfined is a design decision, not an omission. **Known boundary of the fence**: the session user's retained passwordless sudo grant can be re-invoked to bypass the file fence; the two closing paths and their real reachability are in [SECURITY.md](SECURITY.md).
69
-
70
- ## Known limitations
71
-
72
- **The grep / glob tools are available in a WSL session, backed by the distribution's own `grep` / `find`.** The host's two rows are still removed, for the two measured facts: `tool-fs-search` is launched via `ctx.subprocess.spawn()` (`search-core.ts:238`), but its `argv[0]` is the **bundled Windows rg.exe** from `@vscode/ripgrep` (`search-core.ts:174-178`) — it does not exist inside the distro and cannot work with Linux arguments; the distro, meanwhile, ships `/usr/bin/grep` and `/usr/bin/find` (measured: `command -v rg` inside the distro answers nothing). So the `wsl-world` group mounts the two names back onto this plugin's own implementation (`lib/wsl/search-tool.js`), and the model calls `grep` / `glob` in a WSL session as usual — **with the distribution's semantics**: POSIX ERE (no `\d`, no `(?i)`) · `.gitignore` not parsed (only the six VCS directories are skipped) · dotted directories searched · `include` matching the base name only and without brace expansion · patterns interpreted relative to the search root, with `*` and `**/` crossing `/` · a record whose path contains `:` possibly dropped or silently mis-attributed · binary files never matched · a capped result not saved anywhere. **That list is a summary; the document is authoritative for the eight gaps** (gaps 5 and 6 are merged into one item here, and the last item, "a capped result not saved anywhere", is a limit rather than a gap). The eight gaps, the limits, what was not measured and how to verify it yourself are in [Search inside the distribution](docs/SEARCH.en.md).
73
-
74
- **The terminal goes through the PTY bridge, and PTC's `stdio.control` (fd channel) stays explicitly rejected** — `wsl.exe` cannot forward arbitrary descriptors. How the bridge is driven, and its measured preconditions, are in [docs/PTY-BRIDGE.md](docs/PTY-BRIDGE.md).
75
-
76
- **The persistent shell's gap summary**: the model's `bash` is a persistent PTY shell inside the distribution, but it carries two load-bearing boundaries — **(1) the host's `ctx.sandbox` wraps the `wsl.exe` argv**, and this repository's own judgement is that the host sandbox **cannot confine** it (measured: it still runs after the wrapping, which is why the realm provides its own `sandbox` provider and turns that layer into an in-distro fence); **(2) the terminal path has no PID namespace**, and the GUI's web terminal is **unfenced by design** — what is confined is the model's `bash`, not what the user types in the terminal panel. **That list is a summary; the document is authoritative for the gaps**: the nine gaps, the limits, what was not measured, the 3-step manual checklist and how to verify it yourself are in [The persistent shell](docs/PERSISTENT-SHELL.en.md).
77
-
78
- **Confinement does not cover `/dev`, `/proc`, `/sys` or interop**, which is reported honestly in `enforcement: 'partial'`; and the session user's retained passwordless sudo grant can bypass the file fence — see [SECURITY.md](SECURITY.md).
79
-
80
- **File-change tracking inside a WSL workspace polls; it is not event-driven.** `fs.watch` throws `EISDIR` on the distribution's 9P share (for a file target as well as a directory one), so the host backend's chokidar watcher cannot initialize for any WSL target. The plugin polls on the host side instead: a directory compares its entry set (one `readdir` per 300ms; measured 0ms CPU per 3s and 2-3ms latency in this process) and a file — or a path that does not exist yet — watches its stat (`fs.watchFile`, measured 0ms CPU per 3s and 52ms). **A directory target is invalidated by entry-set changes only**: adds, removes and renames are seen; the CONTENT change of a file inside it is not — watch that file's own path to follow it. Mechanisms, measurements and what was not measured are in [WSL file watching](docs/WATCH.en.md).
81
-
82
- ## Verification
83
-
84
- ```powershell
85
- node scripts/verify-all.mjs # all offline suites (20)
86
- node scripts/verify-all.mjs --live # additionally the suites needing the installed plugin + a running host
87
50
  ```
88
-
89
- Per-suite responsibilities, the single-suite commands, and **how the aggregate reads a SKIP** (exit code 2 is a suite's own SKIP; only a skip `DECLARED_SKIPS` names counts, and it prints the precondition it declared; an undeclared skip fails, and a dead declaration fails before any suite runs) are in [docs/VERIFICATION.md](docs/VERIFICATION.md).
90
-
91
- Distro and user are no longer hardcoded: `DSH_WSL_DISTRO` / `DSH_WSL_USER` / `DSH_WSL_HOME` can override, with defaults read live from `wsl.exe`.
92
-
93
- ## Development
94
-
95
- ```bash
96
- git clone https://github.com/zcluo/dsh-wsl-desktop.git
97
- cd dsh-wsl-desktop
98
- node scripts/verify-all.mjs # full offline run
99
- .\scripts\sync.ps1 # stage into the profile and re-point (developer mode: developerTools enabled; a first install needs the wiring first)
100
- # restart DSH Desktop → verify-post-restart.mjs
101
- ```
102
-
103
- Repository layout:
104
-
51
+ plugin_manager install_bundle dsh-wsl-desktop@<version>
105
52
  ```
106
- lib/index.js Host: routes, preset generation, session preset binding
107
- lib/client.js Browser: the W button next to the sidebar "+" + the add-WSL-workspace dialog
108
- lib/wsl/paths.js UNC ↔ Linux ↔ drive-letter translation (pure functions)
109
- lib/wsl/world.js Distro discovery, the wsl.exe execution core, directory facts
110
- lib/wsl/shell.js ShellExecutor implementation (WSL bash + confinement hookup)
111
- lib/wsl/fs.js LocalFileSystem subclass (9P backend + path dialects)
112
- lib/wsl/publish.js The overwrite publication's wait bound (retry + measured tail; pure module, independently testable)
113
- lib/wsl/watch.js Polling strategies for file watching (pure module, independently testable)
114
- lib/wsl/subprocess.js SubprocessRuntime implementation (pipes + terminal; delegates to the host subprocess)
115
- lib/wsl/pty.js Terminal handle: two processes driving the in-distro PTY bridge
116
- lib/wsl/terminal-bridge.py The PTY bridge running inside the distro (python3 stdlib, zero install)
117
- lib/wsl/host-refs.js Cross-module references to the host subprocess provider (required after realm isolation)
118
- lib/wsl/confinement.js Mount-namespace confinement (pure functions + probing, independently testable)
119
- lib/wsl/fence.js Containment comparison and writable-root derivation for the fs fence (pure functions, independently testable)
120
- lib/wsl/preset.js Preset rewriting (pure text, independently testable)
121
- scripts/sync.ps1 Stages the plugin into the profile (then installed by plugin_manager)
122
- scripts/env.mjs Distro/user/home resolution for verification (no hardcoded machine identity)
123
- scripts/verify-*.mjs Standalone verification (no harness needed)
124
- scripts/verify-all.mjs Aggregates and runs all suites (--live adds the suites needing a running host)
125
- ```
126
-
127
- Host-code changes require **restarting DSH Desktop** to load; after a restart, run `verify-post-restart.mjs` first — when old modules are still in effect it reports "please restart" directly instead of giving a false green. **Browser-half changes need no restart**, but "the file changed" is not "the served bytes changed" — the full boundary of hot reload, together with `sync.ps1`'s "keep the two newest" retention rule, is in [docs/ENGINEERING-NOTES.md](docs/ENGINEERING-NOTES.md).
128
-
129
- How the execution world is decided by the session's agent preset, where the session-binding seam is, and the DOM-layer implementation of the UI entry point are in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
130
-
131
- ## Release
132
-
133
- **Maintainer release**: semantic versioning — patch = defect fixes; minor = compatible desktop-major adaptation (each harness break adaptation bumps minor, e.g. the 0.1.7 adaptation in 0.1.x); major = boundary semantics or support-matrix changes. Flow: bump the `package.json` version → full suites + `verify-post-restart.mjs` all green → `npm publish --access public` → git tag. The `files` manifest already includes `lib/wsl/dsh-wsl-confine.sh` (the helper ships with the package).
134
53
 
135
- > ⚠️ **`exports["./client"]` is a hard contract and the easiest thing to delete while editing metadata.** One package that declares `dsh.client` without `exports["./client"]` refuses to compose, and the **whole desktop fails to start** (not just this plugin) — which is why 0.2.0 and 0.2.1 **cannot start at all once installed**. `scripts/verify-modules.mjs` now pins this, so running it before a release catches the regression; the incident is recorded in [docs/ENGINEERING-NOTES.md](docs/ENGINEERING-NOTES.md).
54
+ **Update**: install a new version number. After a desktop-major update, run `verify-post-restart.mjs`
55
+ once first; the "what will surprise you" section says why.
56
+
57
+ **Uninstall**: uninstall through the plugin manager. The `wsl-*` presets this plugin registers are
58
+ withdrawn with it, so no workspace is left pointing at a plugin that no longer exists.
59
+
60
+ ## How to use it
61
+
62
+ In the sidebar workspace title bar there is an extra **W** button to the right of the built-in "+"
63
+ (add workspace). Click it, then go in order:
64
+
65
+ 1. **Pick a distribution.** The installed WSL distributions are listed.
66
+ 2. **Enter a user.** This is the user to enter that distro with, and it decides whose home you browse.
67
+ **Leave it empty for the distro's default user.** If the user does not exist, the error appears in
68
+ place in the popup and browse mode is not entered.
69
+ 3. **Pick a directory.** Browse, or type a Linux path. You land in that user's home directory; the
70
+ host resolves the user database inside the distro with `getent passwd`.
71
+ 4. **Create and open the session.**
72
+
73
+ A few details you will run into:
74
+
75
+ - **Switching distributions asks for the user again**, including re-clicking the one already selected;
76
+ the field starts from the name you confirmed last time.
77
+ - **While a session creation is in flight, the distribution buttons are unclickable**, so a submission
78
+ cannot change target halfway.
79
+ - **This step only decides whose home is browsed, not the session identity.** The session always runs
80
+ inside the distro as the distro's default user; the plugin's configured `username` is not passed
81
+ along with session creation.
82
+ - In the sidebar workspace list, **a workspace whose path is under a WSL UNC root shows a ☁️ icon
83
+ instead of a folder**. UNC is Windows' spelling for a subsystem or network path, of the form
84
+ `\\wsl.localhost\<distro>\home\...`.
85
+ - The built-in "+" keeps the deployment's own behaviour (the Electron directory picker on Desktop);
86
+ this plugin does not override it.
87
+ - Inside an open WSL workspace, the file list and the terminal are the distro's. PowerShell in Windows
88
+ workspaces is unaffected.
89
+
90
+ ## What will surprise you
91
+
92
+ These are real boundaries, not a disclaimer list. Each one tells you where to read further.
93
+
94
+ - **The search tools behave differently from the host.** `grep` / `glob` in a WSL session use the
95
+ distro's own `grep` / `find`, so: the regex dialect is POSIX ERE, which means `\d` and `(?i)` do
96
+ not work · `.gitignore` is not parsed, only the six VCS directories are skipped · dotted
97
+ directories are searched · `include` matches the base name only and has no brace expansion · a
98
+ record whose path contains `:` may be dropped or mis-attributed · binary files never match · a
99
+ capped result is not saved anywhere. The eight differences and the limits are in
100
+ [Search inside the distribution](docs/SEARCH.md).
101
+ - **The terminal goes through a PTY bridge and does not support `stdio.control`.** A terminal needs a
102
+ real pseudo-terminal (PTY) inside the distro, but `wsl.exe` only carries three pipes, so the plugin
103
+ runs a small bridge inside the distro and drives it from two host-side processes. The terminal's
104
+ `stdio.control` fd channel is explicitly rejected because `wsl.exe` cannot forward arbitrary
105
+ descriptors. How the bridge is driven is in [Terminal (PTY bridge)](docs/PTY-BRIDGE.md).
106
+ - **The file fence governs the model, not you.** The GUI's web terminal is **unfenced by design** —
107
+ what you type in the terminal panel is the same as opening a shell in the distro yourself; what is
108
+ confined is the model's `bash`. Also, **the terminal path has no PID namespace** — the Linux
109
+ mechanism that lets a group of processes see only themselves and not the rest of the system —
110
+ while the one-shot command path keeps it. Both boundaries are written out in
111
+ [The persistent shell](docs/PERSISTENT-SHELL.md).
112
+ - **`/dev`, `/proc`, `/sys` and interop are outside the fence.** They are not file storage, so the
113
+ plugin reports its own `enforcement` field as `partial` (partial coverage) instead of calling it
114
+ "sandboxed".
115
+ - **The subprocess surface is not fenced.** The host's own subprocess surface — terminating managed
116
+ processes, output overflow, reclaiming them — is not bound by the file fence; that is a design
117
+ decision, not an omission. See [Security and trust boundaries](SECURITY.md).
118
+ - **The retained passwordless sudo grant can bypass the file fence.** The session user keeps the grant
119
+ the plugin itself needs to build the fence, so a confined command can re-invoke it. There are two
120
+ closing paths — the helper, and NO_NEW_PRIVS, a kernel flag that stops a process from ever gaining
121
+ privileges — and their real reachability is in [Security and trust boundaries](SECURITY.md).
122
+ - **File-change watching polls; it is not event-driven.** 9P is the share WSL uses to expose the
123
+ distro's files to Windows, and `fs.watch` throws `EISDIR` on it. So the plugin polls on the host
124
+ side: a directory checks its entry set every 300ms, a file gets a `stat` every 300ms. **A directory
125
+ target is invalidated by entry changes only** — adds, removes and renames are seen, a content change
126
+ of a file inside it is not; watch that file's own path to follow content. Mechanisms and
127
+ measurements are in [WSL file watching](docs/WATCH.md).
128
+ - **A desktop update can break the plugin.** The plugin depends on the desktop framework's APIs — the
129
+ harness — and changes to them interrupt it (0.1.6 → 0.1.7 broke four places in one go). So after
130
+ updating the desktop, run `node scripts/verify-post-restart.mjs` once and only keep going when
131
+ everything is green.
132
+ - **Who it is for.** For now it suits the plugin author or an operator of the same trust level, for
133
+ long-term use. Distribution to third-party users, or unattended high-value environments, are not
134
+ suitable yet — the reasoning is in [SECURITY.md](SECURITY.md).
135
+
136
+ ## Where to read more
137
+
138
+ - [Security and trust boundaries](SECURITY.md) — trust boundaries, disclosed unconfined surfaces, audit results
139
+ - [Architecture and the execution world](docs/ARCHITECTURE.md) — how a session binds its execution world, and why the UI does what it does
140
+ - [Confinement (Linux side)](docs/CONFINEMENT.md) — the mount-namespace fence, the helper, measured preconditions
141
+ - [The fs tool fence](docs/FS-FENCE.md) — how out-of-bounds file access is decided
142
+ - [Terminal (PTY bridge)](docs/PTY-BRIDGE.md) — how the terminal gets inside the distro
143
+ - [The persistent shell](docs/PERSISTENT-SHELL.md) — the model's `bash`: gap list, limits, manual checklist
144
+ - [Search inside the distribution](docs/SEARCH.md) — the eight semantic differences, the limits, how to verify yourself
145
+ - [WSL file watching](docs/WATCH.md) — the polling strategy and its boundaries
146
+ - [Verification](docs/VERIFICATION.md) — what each suite is responsible for, and how a SKIP is ruled
147
+ - [Engineering notes](docs/ENGINEERING-NOTES.md) — measured constraints, repository layout, release flow, install shape
148
+ - [Distro support matrix](docs/DISTRO-SUPPORT.md) — which distros were verified, and what each one needs
149
+ - [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the two proposals to the harness upstream
150
+ - [Architecture and flow diagrams](docs/diagrams.md) — interactive architecture, sequence and lifecycle diagrams
151
+
152
+ Three of the linked documents — the distro support matrix, the upstream proposals and the diagram
153
+ index — are **Chinese-only**, because no English text for them exists. Translating them is a separate
154
+ piece of work, not a rename.
155
+
156
+ ## For maintainers
157
+
158
+ This section is for people changing this repository; if you only want to use the plugin, the above is
159
+ enough.
160
+
161
+ - `README.md` and this file are a declared bilingual pair: `scripts/verify-docs.mjs` requires both
162
+ sides to name the same repository artifacts.
163
+ - Self-check: `node scripts/verify-all.mjs` runs every offline suite; add `--live` for the suites that
164
+ need the installed plugin and a running host. Per-suite responsibilities and the SKIP ruling are in
165
+ [Verification](docs/VERIFICATION.md).
166
+ - The repository layout, the install shape and the release flow are in
167
+ [Engineering notes](docs/ENGINEERING-NOTES.md). What semantic versions mean in this repository,
168
+ and the `npm publish` steps, are documented there too.
169
+ - ⚠️ **`exports["./client"]` is a hard contract.** One package that declares `dsh.client` without
170
+ `exports["./client"]` makes the host refuse to compose, and the **whole desktop fails to start**.
171
+ `scripts/verify-modules.mjs` pins this, so running it before a release catches the regression. The
172
+ incident is in [Engineering notes](docs/ENGINEERING-NOTES.md).
173
+ - Distribution and user are not hardcoded: `DSH_WSL_DISTRO` / `DSH_WSL_USER` / `DSH_WSL_HOME` can
174
+ override, with defaults read live from `wsl.exe`.
136
175
 
137
176
  ## License
138
177
 
package/README.md CHANGED
@@ -2,135 +2,145 @@
2
2
 
3
3
  简体中文 | [English](README.en.md)
4
4
 
5
- 把 WSL 发行版变成 DSH Desktop 里的**一等执行世界**:工作区可以落在发行版内,shell、文件工具、subprocess 与终端随之全部在发行版里运行——而同一实例里的 Windows 工作区仍走宿主自己的沙箱后端,两者并存。
5
+ ## 这是什么
6
6
 
7
- > **生产状态:受控单机生产可用**(见下方边界定义)。需要 **DSH Desktop 0.1.7+**(预设通过运行时注册而非目录加载,旧版宿主会在激活时明确拒绝)。核心能力已活体验收;安全审计 run-1(5 候选闭环)与 run-2(4 confirmed 已修复 + 1 rejected 转加固)均完成,报告见 `security-audit-skill/dsh-wsl-desktop/run-{1,2}/`。逐项证据强度见下方能力表。
8
- >
9
- > **边界定义**:
10
- > - ✅ **适合**:插件作者本人或同信任级别操作者,在明确支持的发行版(见 [发行版支持矩阵](docs/DISTRO-SUPPORT.md))与 0.1.7+ 桌面上长期使用。
11
- > - ⚠️ **条件**:confined 模式的信任边界依赖 NO_NEW_PRIVS(debian 系现代 setpriv 满足;setpriv 不认 `--no-new-privs` 时 confined 模式**根本不运行**——direct runner 在测到 `false` 时拒绝,helper 的降权用同一个标志、同样失败关闭,见 [安全与信任边界](SECURITY.md))。
12
- > - ❌ **尚不适合**:分发给第三方用户(缺桌面版本门控与发行版矩阵)、无人值守高价值环境(subprocess 面不设防为已披露设计;对 harness 上游的两项 API 提案见 [docs/UPSTREAM-PROPOSALS.md](docs/UPSTREAM-PROPOSALS.md))。
7
+ 一个 DSH Desktop 插件。它让你的 WSL 发行版变成可以直接干活的地方:把工作区建在发行版里,AI 的
8
+ shell 命令、文件读写、搜索和终端就都跑在发行版内,而不是跑在 Windows 上。
13
9
 
14
- ## 目录
10
+ ## 为什么你会想要它
15
11
 
16
- - **本页**:[目标能力](#目标能力) · [安装 / 更新 / 卸载](#安装--更新--卸载) · [快速开始](#快速开始) · [桌面更新纪律](#桌面更新纪律强制) · [安全与信任边界](#安全与信任边界) · [已知限制](#已知限制) · [校验](#校验) · [开发](#开发) · [发版](#发版)
17
- - **深入材料**:[安全与信任边界](SECURITY.md) · [架构与执行世界](docs/ARCHITECTURE.md) · [Linux 侧约束](docs/CONFINEMENT.md) · [fs 工具的围栏](docs/FS-FENCE.md) · [终端 PTY 桥](docs/PTY-BRIDGE.md) · [WSL 文件变更监听](docs/WATCH.md) · [校验与 SKIP 裁定](docs/VERIFICATION.md) · [工程笔记](docs/ENGINEERING-NOTES.md) · [发行版支持矩阵](docs/DISTRO-SUPPORT.md) · [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) · [架构与流程图](docs/diagrams.md)
12
+ 插件给每个 WSL 会话配一套"预设",预设就是会话启动时拿到的那一整套工具配置。这套配置把 shell、
13
+ 文件读写、搜索、终端都指向发行版,所以:
18
14
 
19
- ## 目标能力
15
+ - **你的 Linux 工具真的能用。** 工作区放在发行版里,AI 用的是发行版自己的 `bash`、`grep`、`find`、
16
+ 解释器和包。
17
+ - **命令之间不再各过各的。** 模型侧的 `bash` 是一条常驻 shell,上一次调用留下的当前目录和环境变量,
18
+ 下一次调用还看得见。
19
+ - **两种工作区可以同时开着。** 同一个窗口里,Windows 工作区照旧跑在宿主自己的沙箱后端上,WSL 工作区
20
+ 跑在发行版里,互不干扰。
21
+ - **可以顺手用到 Windows 那边的东西。** 在发行版里能调用宿主机的命令;WSL 把这项功能叫 interop,
22
+ 它在发行版里默认开着。
20
23
 
21
- **活体验收通过后的诚实状态**(`verify-post-restart.mjs` 最近一次实跑:**58 PASS / 0 FAIL**、尾部 `NO AUTOMATED FAILURES; 3 manual step(s) pending`、`EXIT=0`——包括搜索的两条活体行与常驻 shell 的那几条;**剩 3 条浏览器专属的人工项**,清单见 [常驻 shell](docs/PERSISTENT-SHELL.md#人工核验清单浏览器专属不可自动化),加离线 20 套件;下面每条都注明证据强度):
24
+ ## 装之前要有什么
22
25
 
23
- | # | 能力 | 状态 |
24
- |---|---|---|
25
- | ① | 工作区可选择 WSL 发行版里的 Linux 目录 | 对话框与宿主调用已实现并活体通过(listDir/checkPath/resolveHome/工作区注册与清理);**浏览器内交互已由操作者人工确认**(门控流、目录浏览、终端面板均正常) |
26
- | ② | 该工作区内的 shell / 文件工具 / subprocess / 终端 在 WSL 里工作,且可从 WSL 调用宿主机命令 | **活体验收 PASS**:绑定(创建请求命名 preset)、shell 进发行版、fs 工具 Linux 寻址、越界写拒绝、subprocess POSIX 环境、bash 工具、工具层约束;终端传输由 PTY 套件覆盖。**模型侧的 `bash` 是发行版内的常驻 PTY shell,cwd 与环境变量跨调用保留**(活体验证:两次调用读数一致;缺口清单见 [常驻 shell](docs/PERSISTENT-SHELL.md)) |
27
- | ③ | 同一实例里 Windows 与 WSL 工作区并存 | **活体验收 PASS**:Windows 工作区停留在宿主 preset(PowerShell 可用、无 bash),WSL 会话并行运行 confined realm |
28
- | ④ | WSL 侧的 Linux 沙箱约束 | **shell 侧已修并活体验证**:运行时枚举所有 `rw` 挂载逐个改只读(实测 `/mnt/c`、`/dev/shm`、`/run/user/<uid>` 从 WRITABLE 变 READONLY)、失败即拒(保留退出码 97)、`enforcement` 如实报 `partial`。**fs 侧围栏已实现并活体验证**(越界写拒绝 PASS,见 [fs 工具的围栏](docs/FS-FENCE.md))|
26
+ - **WSL2**。不是 WSL1。
27
+ - **一个受支持的发行版。** Debian、Ubuntu、Fedora/RHEL、Arch 已实测通过,Alpine 装上
28
+ `bash` 与 `util-linux-misc` 后也可以。清单与每种发行版的安装命令见
29
+ [发行版支持矩阵](docs/DISTRO-SUPPORT.md)。
30
+ - **DSH Desktop 0.1.7 或更高。** 旧版宿主在激活时会明确拒绝。
31
+ - **会话用户能免密 sudo,并且有 `bash` 和 `python3`。** 这一条最容易卡住。受限模式要在发行版内部
32
+ 搭一个 mount namespace(让进程看到一份独立挂载表的 Linux 机制),把文件系统改成只读,而 WSL 内核
33
+ 不允许在 user namespace 里做 bind mount,所以只能用 `sudo -n`。没有免密 sudo 时受限模式会
34
+ **明确失败**,不会退回去裸跑。
35
+ - **建议再装一个 helper。** helper 是装在发行版里的一份小脚本 `dsh-wsl-confine`,用来闭合"用户保留的
36
+ sudo 授权"这条边界。安装命令见 [Linux 侧约束](docs/CONFINEMENT.md)。它是可选的,但推荐。
29
37
 
30
- **哪些能力有保留**(证据强度就是上表状态列的措辞):终端不支持 `stdio.control`——写在「已知限制」里,不藏。常驻 shell 的缺口同样写在「已知限制」里,权威清单在 [常驻 shell](docs/PERSISTENT-SHELL.md)。
38
+ ## 怎么装
31
39
 
32
- ## 安装 / 更新 / 卸载
40
+ **插件市场 / 插件管理器(推荐)**:安装 `dsh-wsl-desktop@<version>`。桌面会自动把包装进 pnpm 工作区,
41
+ 并把 bundle patch(插件挂进桌面的那份配置)挂上,重启即用。
33
42
 
34
- **用户安装(生产通道)**:本插件以 npm 包形式发布,形态对齐 dsh-better-sidebar 等成熟插件(`files` 精确清单 + `cordis.patch.yml` bundle patch + `dsh.client.inject` 客户端接线 + `manifestVersion`)。
35
-
36
- - 插件市场 / 插件管理器:安装 `dsh-wsl-desktop@<version>`——桌面自动完成 pnpm 接线与 bundle patch 挂载,重启即用。
37
- - 命令行等价:`plugin_manager install_bundle dsh-wsl-desktop@<version>`。
38
- - **安装前提**:WSL2 + 目标发行版满足[支持矩阵](docs/DISTRO-SUPPORT.md)——会话用户 NOPASSWD sudo、bash、python3;推荐同时安装 dsh-wsl-confine helper 闭合围栏边界(安装命令见 [docs/CONFINEMENT.md](docs/CONFINEMENT.md))。
39
- - **更新**:安装新版本号即可;桌面大版本更新后按「桌面更新纪律」先跑 `verify-post-restart.mjs`。
40
- - **卸载**:插件管理器卸载即同时撤回本插件注册的 wsl-* 预设(disposer 生命周期保证)。
41
-
42
- > **版本对应关系**:当前 `0.3.x` 系列要求 DSH Desktop **0.1.7+**。语义化版本的含义见「发版」——`minor` 就是"适配了某个新的桌面大版本",所以**桌面大版本变了要先看这里有没有对应的 minor**。
43
-
44
- ## 快速开始
45
-
46
- 侧栏工作区标题栏里,自带的"+"(添加工作区)右边多一个 **W** 按钮。点开是本插件的工作区对话框,顺序是:选发行版 → 输入要进入该发行版的用户(**留空 = 发行版默认用户**)→ 浏览或输入 Linux 目录 → 创建并打开会话。
47
-
48
- 进入浏览后,路径直接落在该用户的主目录(宿主在发行版内用 `getent passwd` 解析用户数据库)。输入的用户不存在时,错误就地显示在弹层里,不会进入浏览。切换发行版——**包括重新点击当前已选中的那个**——都会重新弹出用户输入框,并带上一次确认过的用户名作为起点。创建会话进行中时发行版按钮暂时不可点。
49
-
50
- 边界要说清楚:这一步只决定"浏览谁的 home",**不决定会话身份**——会话在发行版里始终以发行版默认用户执行(`username` 是插件配置,不随会话创建传递)。
51
-
52
- 侧栏工作区列表里,**路径落在 WSL UNC 下的工作区,行首图标是 ☁️ 而不是文件夹**。"+" 保持部署自带的行为(Desktop 上是 Electron 目录选择器),本插件不改写它。
53
-
54
- 界面与 DOM 层为什么这么做("隐藏必须用 `display`、不能用 `remove()`"、"不要用定时器兜底"这类实测结论),见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
55
-
56
- ## 桌面更新纪律(强制)
57
-
58
- 每次 DSH Desktop 更新后,插件可能因 harness 内部 API 变化而失效(0.1.6→0.1.7 即断裂四处)。**纪律:更新桌面 → 跑 `node scripts/verify-post-restart.mjs` → 全绿才继续使用;任何一次红都先修再干。** 该套件覆盖预设注册、执行世界、会话绑定、Windows 隔离与对话框契约;新宿主的破坏会在这些断言上现形,而不是静默降级成宿主世界。
59
-
60
- ## 安全与信任边界
61
-
62
- - **宿主侧**:Windows 工作区会话仍跑在 DSH 自带的沙箱后端里,本插件不替换它们。
63
- - **发行版内 shell 侧**:在 mount namespace 里把 `/` 改只读,只留工作区与一个私有 `/tmp` 可写;**约束建立失败即拒绝执行**,不裸跑。机制与实测前提见 [docs/CONFINEMENT.md](docs/CONFINEMENT.md)。
64
- - **发行版内 fs 侧**:`WslFileSystem` 自带围栏,两个变更入口都先过 `checkedTarget`,越界抛结构化 `FS_SANDBOX_DENIED`。见 [fs 工具的围栏](docs/FS-FENCE.md)。
65
-
66
- **已披露的不设防面**:宿主 subprocess 面不设防是设计取舍,不是遗漏。**围栏的已知边界**:会话用户保留的免密 sudo 授权可以重新调用它绕过文件围栏,两条闭合路径与它们的实际可达性见 [SECURITY.md](SECURITY.md)。
67
-
68
- ## 已知限制
69
-
70
- **grep / glob 工具在 WSL 会话里可用,后端是发行版自己的 `grep` / `find`。** 宿主那两行依然被移除,理由是实测的两件事:`tool-fs-search` 虽然通过 `ctx.subprocess.spawn()` 启动(`search-core.ts:238`),但它的 `argv[0]` 是 `@vscode/ripgrep` 提供的**打包 Windows rg.exe**(`search-core.ts:174-178`)——在发行版里既不存在、也无法用 Linux 参数工作;而发行版自带 `/usr/bin/grep` 与 `/usr/bin/find`(实测:发行版里 `command -v rg` 回答为空)。所以 `wsl-world` 组把这两个名字挂回本插件自己的实现(`lib/wsl/search-tool.js`),模型在 WSL 会话里照常调用 `grep` / `glob`——**但语义是发行版工具的**:POSIX ERE(没有 `\d`、`(?i)`)· 不解析 `.gitignore`(只排除六个 VCS 目录)· 点目录会被搜到 · `include` 只匹配 basename 且没有花括号 · 模式按搜索根相对解释、`*` 与 `**/` 跨 `/` · 路径含 `:` 的记录可能被丢弃或被静默错配 · 二进制文件永不被匹配 · 达到上限的截断不落盘。**这串要点是摘要,缺口的权威清单在文档里**(文档八条中第 5、6 条在这里并成了一条;末条「截断不落盘」是限额,不是缺口)。八条缺口、限额、未测项与怎么自己验,见 [发行版内搜索](docs/SEARCH.md)。
71
-
72
- **终端走 PTY 桥,PTC 的 `stdio.control`(fd 通道)明确拒绝**——`wsl.exe` 无法转发任意描述符。桥的驱动方式与实测前提见 [docs/PTY-BRIDGE.md](docs/PTY-BRIDGE.md)。
73
-
74
- **常驻 shell 的缺口摘要**:模型侧的 `bash` 是发行版内的常驻 PTY shell,但它有两条要命的边界——**(1)宿主 `ctx.sandbox` 会包一层 `wsl.exe` 的 argv**,而本仓自己判定宿主沙箱对它**无法约束**(实测:包装之后仍然跑得起来,所以 realm 里自建了 `sandbox` 提供者,把这一层变成发行版内的约束);**(2)终端这条路没有 PID namespace**,而 GUI 的网页终端按设计**不受 fence 约束**——受约束的是模型的 `bash`,不是用户在终端面板里敲的那条。**这串只是摘要,缺口的权威清单在文档里**:九条缺口、限额、未测项、3 条人工核验清单与怎么自己验,见 [常驻 shell](docs/PERSISTENT-SHELL.md)。
75
-
76
- **约束覆盖不到 `/dev`、`/proc`、`/sys` 与 interop**,如实报在 `enforcement: 'partial'` 里;而会话用户保留的免密 sudo 授权可绕过文件围栏,闭合路径见 [SECURITY.md](SECURITY.md)。
77
-
78
- **WSL 工作区里的文件变更跟踪是轮询的,不是事件驱动的。** 发行版的 9P 共享上 `fs.watch` 会抛 `EISDIR`(文件与目录目标都抛),宿主后端的 chokidar 监听因此在 WSL 目标上根本无法初始化;插件改为在宿主侧轮询——目录看条目集合(每 300ms 一次 `readdir`,实测本进程 0ms CPU/3s、延迟 2–3ms),文件与尚不存在的路径走 `fs.watchFile`(每 300ms 一次 `stat`,实测 0ms CPU/3s、延迟 52ms)。**目录目标只对条目集合变化失效**:新增、删除、改名可见,目录内某个文件的**内容**变化不可见——要盯着某个文件,就按文件路径监听它。机制、实测与未测项见 [WSL 文件变更监听](docs/WATCH.md)。
79
-
80
- ## 校验
81
-
82
- ```powershell
83
- node scripts/verify-all.mjs # 全部离线套件(20 个)
84
- node scripts/verify-all.mjs --live # 追加需要已安装插件 + 运行中宿主的套件
85
- ```
86
-
87
- 逐套件职责、单跑命令、以及**聚合器怎么读 SKIP**(退出码 2 是套件自己的 SKIP;只有 `DECLARED_SKIPS` 点名的跳过才算数并打印它的前提,未声明的跳过判红,失效的声明在任何套件运行之前先判红),见 [docs/VERIFICATION.md](docs/VERIFICATION.md)。
88
-
89
- 发行版与用户不再硬编码:`DSH_WSL_DISTRO` / `DSH_WSL_USER` / `DSH_WSL_HOME` 可覆盖,默认从 `wsl.exe` 现读。
90
-
91
- ## 开发
92
-
93
- ```bash
94
- git clone https://github.com/zcluo/dsh-wsl-desktop.git
95
- cd dsh-wsl-desktop
96
- node scripts/verify-all.mjs # 离线全量
97
- .\scripts\sync.ps1 # stage 进 profile 并重指向(开发者模式:developerTools 开启;首次安装需先接线)
98
- # 重启 DSH Desktop → verify-post-restart.mjs
99
- ```
100
-
101
- 仓库布局:
43
+ **命令行等价**:
102
44
 
103
45
  ```
104
- lib/index.js Host:路由、预设生成、会话预设绑定
105
- lib/client.js 浏览器:侧栏"+"旁的 W 按钮 + 添加 WSL 工作区对话框
106
- lib/wsl/paths.js UNC ↔ Linux ↔ 盘符 互译(纯函数)
107
- lib/wsl/world.js 发行版发现、wsl.exe 执行核、目录事实
108
- lib/wsl/shell.js ShellExecutor 实现(WSL bash + 约束接入)
109
- lib/wsl/fs.js LocalFileSystem 子类(9P 后端 + 路径方言)
110
- lib/wsl/publish.js 覆盖发布的等待边界(重试 + 实测尾部;纯模块,可独立测试)
111
- lib/wsl/watch.js 文件变更监听的轮询策略(纯模块,可独立测试)
112
- lib/wsl/subprocess.js SubprocessRuntime 实现(管道 + 终端;委托宿主 subprocess)
113
- lib/wsl/pty.js 终端句柄:两个进程驱动发行版内的 PTY 桥
114
- lib/wsl/terminal-bridge.py 发行版内运行的 PTY 桥(python3 标准库,零安装)
115
- lib/wsl/host-refs.js 宿主 subprocess provider 的跨模块引用(realm 隔离后必需)
116
- lib/wsl/confinement.js mount namespace 约束(纯函数 + 探测,可独立测试)
117
- lib/wsl/fence.js fs 围栏的包含性比较与可写根推导(纯函数,可独立测试)
118
- lib/wsl/preset.js 预设重写(纯文本,可独立测试)
119
- scripts/sync.ps1 把插件 stage 进 profile(随后由 plugin_manager 安装)
120
- scripts/env.mjs 校验用的发行版/用户/home 解析(不硬编码机器身份)
121
- scripts/verify-*.mjs 独立校验(不需要 harness)
122
- scripts/verify-all.mjs 聚合运行全部套件(--live 追加需要运行中宿主的套件)
46
+ plugin_manager install_bundle dsh-wsl-desktop@<version>
123
47
  ```
124
48
 
125
- 宿主代码改动需要**重启 DSH Desktop** 才会加载,重启后先跑 `verify-post-restart.mjs`(它会在旧模块仍生效时直接报「请重启」而不是给假绿)。**浏览器半边改动不需要重启**,但"文件已改"不等于"投放已变"——热更新的完整边界、以及 `sync.ps1` 的"保留最新两代"规则,见 [docs/ENGINEERING-NOTES.md](docs/ENGINEERING-NOTES.md)。
126
-
127
- 执行世界由会话的 agent preset 决定、会话绑定的接缝在哪、界面入口的 DOM 层实现,见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
128
-
129
- ## 发版
130
-
131
- **维护者发版**:语义化版本——patch = 缺陷修复;minor = 兼容的桌面大版本适配(每次 harness 断裂适配后升 minor,如 0.1.x 的 0.1.7 适配);major = 边界语义或支持矩阵变化。流程:改 `package.json` version → 全量套件 + `verify-post-restart.mjs` 全绿 → `npm publish --access public` → 打 git tag。`files` 清单已含 `lib/wsl/dsh-wsl-confine.sh`(helper 随包分发)。
132
-
133
- > ⚠️ **`exports["./client"]` 是硬性契约,改元数据时最容易误删。** 只要有一个包声明了 `dsh.client` 却没有 `exports["./client"]`,宿主就直接拒绝组合,**整张桌面启动失败**(不是本插件单独失效)——v0.2.0 与 0.2.1 因此装上去就起不来。`scripts/verify-modules.mjs` 现在钉住这条,发版前跑它即可拦住;事故经过见 [docs/ENGINEERING-NOTES.md](docs/ENGINEERING-NOTES.md)。
49
+ **更新**:装一个新版本号就行。桌面大版本更新之后,先跑一次 `verify-post-restart.mjs`
50
+ (见「会意外的地方」里桌面更新那一条)。
51
+
52
+ **卸载**:用插件管理器卸载即可。本插件注册的 `wsl-*` 预设会一起撤回,不会留下指向不存在插件的工作区。
53
+
54
+ ## 怎么用
55
+
56
+ 侧栏工作区标题栏里,自带的「+」(添加工作区)右边多出一个 **W** 按钮。点它,然后按顺序走:
57
+
58
+ 1. **选发行版。** 已安装的 WSL 发行版会列出来。
59
+ 2. **输入用户。** 这是要进入该发行版的用户,用来决定"浏览谁的 home"。**留空 = 发行版默认用户。**
60
+ 用户名不存在时,错误就地显示在弹层里,不会进入浏览。
61
+ 3. **选目录。** 浏览或直接输入 Linux 路径。进来时路径落在上一步那个用户的主目录,
62
+ 宿主在发行版内用 `getent passwd` 解析用户数据库。
63
+ 4. **创建并打开会话。**
64
+
65
+ 几个会碰到的细节:
66
+
67
+ - **切换发行版会重新问用户**,包括重新点当前已选中的那个;输入框会带上一次确认过的名字作为起点。
68
+ - **创建会话进行中时,发行版按钮暂时点不动**,避免提交到一半换了目标。
69
+ - **这一步只决定浏览谁的 home,不决定会话身份。** 会话在发行版里始终以发行版的默认用户执行;
70
+ 插件配置里的 `username` 不随会话创建传递。
71
+ - 侧栏工作区列表里,**路径落在 WSL UNC 下的工作区,行首图标是 ☁️ 而不是文件夹**。UNC 是 Windows
72
+ 表示网络/子系统路径的写法,形如 `\\wsl.localhost\<发行版>\home\...`。
73
+ - 自带的「+」保持部署原本的行为(桌面上是 Electron 的目录选择器),本插件不改写它。
74
+ - 已经打开的 WSL 工作区里,文件列表和终端都是发行版内的。Windows 工作区里的 PowerShell 不受影响。
75
+
76
+ ## 会意外的地方
77
+
78
+ 这些都是真的边界,不是缺陷清单的免责声明。每条都给了读下去的地方。
79
+
80
+ - **搜索工具的语义和宿主不一样。** WSL 会话里的 `grep` / `glob` 用的是发行版自己的
81
+ `grep` / `find`。所以正则方言是 Linux 上 grep 的标准方言 POSIX ERE,`\d` 与 `(?i)` 都不可用。
82
+ 它也不解析 `.gitignore`,只排除六个 VCS 目录。点目录会被搜到;`include` 只匹配
83
+ basename 且不支持花括号;路径含 `:` 的记录可能被丢弃或错配;二进制文件永远不匹配;
84
+ 到上限的结果不落盘。完整八条与限额见 [发行版内搜索](docs/SEARCH.md)。
85
+ - **终端走 PTY 桥,不支持 `stdio.control`。** 终端要在发行版里真的开出一个伪终端(PTY),
86
+ 而 `wsl.exe` 只有三条管道,所以插件在发行版里跑一个小桥,由宿主侧两个进程驱动它。终端的
87
+ `stdio.control` 那条 fd 通道明确拒绝,因为 `wsl.exe` 无法转发任意描述符。桥怎么驱动见
88
+ [终端(PTY 桥)](docs/PTY-BRIDGE.md)。
89
+ - **文件围栏管的是模型,不是你。** GUI 的网页终端按设计**不受围栏约束**——你在终端面板里敲的命令
90
+ 和在发行版里直接开一个 shell 一样;受约束的是模型侧那条 `bash`。另外**终端这条路没有
91
+ PID namespace**:PID namespace 是让一组进程只看得见自己那棵进程树的 Linux 机制,而终端这条路
92
+ 没有它,一次性命令路径则有。两条边界的完整说明见 [常驻 shell](docs/PERSISTENT-SHELL.md)。
93
+ - **`/dev`、`/proc`、`/sys` 和 interop 在围栏之外。** 它们不是文件存储,所以插件把自我报告的
94
+ `enforcement` 字段报成 `partial`(部分覆盖),而不是说成"已沙箱化"。
95
+ - **subprocess 这一面不设防。** 托管进程的终止、输出溢出与回收,都仍归宿主自己的 subprocess
96
+ provider,不受文件围栏约束。这是设计取舍,不是遗漏。见 [安全与信任边界](SECURITY.md)。
97
+ - **保留的免密 sudo 能绕过文件围栏。** 会话用户保留着插件建立围栏时自己要用的那份授权,所以被约束
98
+ 的命令可以重新调用它。两条闭合路径是 helper 与 NO_NEW_PRIVS。后者是内核标志,让进程再也拿不到
99
+ 提权。两条路径的实际可达性见 [安全与信任边界](SECURITY.md)。
100
+ - **文件变更监听是轮询的,不是事件驱动的。** 9P 是 WSL 把发行版文件暴露给 Windows 的那套共享,
101
+ 在它上面 `fs.watch` 会抛 `EISDIR`。所以插件改为在宿主侧轮询:目录每 300ms 看一次条目集合,
102
+ 文件每 300ms `stat` 一次。**目录目标只对条目变化生效**——新增、删除、改名可见,目录里某个文件的
103
+ 内容变化不可见;要盯内容就按文件路径监听它。机制与实测见
104
+ [WSL 文件变更监听](docs/WATCH.md)。
105
+ - **桌面更新后插件可能失效。** 插件依赖桌面内部的框架(harness)的 API,这些 API 变化会打断它
106
+ (0.1.6 → 0.1.7 一次断了四处)。所以更新桌面之后先跑一次
107
+ `node scripts/verify-post-restart.mjs`,全绿再继续用。
108
+ - **适合谁用。** 目前适合插件作者本人或同信任级别的操作者长期使用。分发给第三方用户、或放进
109
+ 无人值守的高价值环境,都还不适合——理由写在 [SECURITY.md](SECURITY.md)。
110
+
111
+ ## 想读更多
112
+
113
+ - [安全与信任边界](SECURITY.md) — 信任边界、已披露的不设防面、审计结论
114
+ - [执行世界与架构](docs/ARCHITECTURE.md) — 会话怎么绑定执行世界,界面为什么这样做
115
+ - [Linux 侧约束](docs/CONFINEMENT.md) — mount namespace 围栏、helper、实测前提
116
+ - [fs 工具的围栏](docs/FS-FENCE.md) — 文件读写的越界判定
117
+ - [终端(PTY 桥)](docs/PTY-BRIDGE.md) — 终端是怎么跑进发行版的
118
+ - [常驻 shell](docs/PERSISTENT-SHELL.md) — 模型侧 `bash` 的缺口清单、限额、人工核验清单
119
+ - [发行版内搜索](docs/SEARCH.md) — 八条语义差异、限额、怎么自己验
120
+ - [WSL 文件变更监听](docs/WATCH.md) — 轮询策略与它的边界
121
+ - [校验](docs/VERIFICATION.md) — 每个套件管什么,以及跳过(SKIP)怎么裁定
122
+ - [工程笔记](docs/ENGINEERING-NOTES.md) — 实测约束、仓库布局、发版流程、安装形态
123
+ - [发行版支持矩阵](docs/DISTRO-SUPPORT.md) — 哪些发行版验证过,每种缺什么
124
+ - [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness 上游的两项提案
125
+ - [架构与流程图](docs/diagrams.md) — 可交互的架构、时序与生命周期图
126
+
127
+ 其中三份——发行版支持矩阵、上游 API 提案、架构与流程图索引——**只有中文版**,因为目前没有对应的
128
+ 英文文本。把它们翻成英文是一件独立的工作,不是改个文件名。
129
+
130
+ ## 给维护者
131
+
132
+ 这一节是给要改这个仓库的人看的;只想用插件的话,上面已经够了。
133
+
134
+ - `README.en.md` 与本文件是一对声明过的双语版:`scripts/verify-docs.mjs` 要求两边命名同一批仓库文件。
135
+ - 自检:`node scripts/verify-all.mjs` 跑全部离线套件;加 `--live` 追加需要已安装插件与运行中宿主的
136
+ 套件。逐套件职责与 SKIP 裁定见 [校验](docs/VERIFICATION.md)。
137
+ - 仓库布局、安装形态与发版流程见 [工程笔记](docs/ENGINEERING-NOTES.md)。语义化版本在这个仓库里的
138
+ 具体含义、以及 `npm publish` 的步骤都在那里。
139
+ - ⚠️ **`exports["./client"]` 是硬性契约。** 一个包声明了 `dsh.client` 却没有
140
+ `exports["./client"]`,宿主会直接拒绝组合,**整张桌面启动失败**。`scripts/verify-modules.mjs`
141
+ 钉住这条,发版前跑它就能拦住。事故经过见 [工程笔记](docs/ENGINEERING-NOTES.md)。
142
+ - 发行版与用户不硬编码:`DSH_WSL_DISTRO` / `DSH_WSL_USER` / `DSH_WSL_HOME` 可覆盖,
143
+ 默认从 `wsl.exe` 现读。
134
144
 
135
145
  ## 许可证
136
146
 
package/lib/wsl/search.js CHANGED
@@ -13,10 +13,10 @@
13
13
  * filtering happen in Node, on the parsed output.
14
14
  *
15
15
  * The other half of the job is what comes BACK. The distribution's tools print formats of
16
- * their own (`path:line:text` with no escaping, `%T@ %p` NUL-separated), and the parsers
17
- * below turn that print into the host tool's shape, applying the caps there — in Node,
18
- * because the vectors ask for everything and only the consumer can stop. The sort is Node's
19
- * too: a realm has no shell to pipe into `sort`.
16
+ * their own (`path:line:text` with no escaping; an mtime line followed by the NUL-terminated
17
+ * path), and the parsers below turn that print into the host tool's shape, applying the caps
18
+ * there — in Node, because the vectors ask for everything and only the consumer can stop. The
19
+ * sort is Node's too: a realm has no shell to pipe into `sort`.
20
20
  *
21
21
  * What a FAILED run means is decided here as well (`searchFailure`), in the host's own two
22
22
  * names, so the tool that raises the failure does not get to invent a vocabulary of its
@@ -80,8 +80,46 @@ export function grepArgv(input, caps) {
80
80
  }
81
81
 
82
82
  /**
83
- * One `find` argv vector: VCS pruned by -prune, the pattern anchored to the root, mtime
84
- * printed for the sort in Node.
83
+ * One `find` argv vector: VCS pruned by -prune, the pattern anchored to the root, and each hit
84
+ * paired with its mtime so the sort can happen in Node.
85
+ *
86
+ * PORTABLE ON PURPOSE, for the reason `pty.js`'s sweep already records: the mtime used to come
87
+ * from `-printf '%T@ %p\0'`, which is a GNU EXTENSION. On a busybox `find` (Alpine, many minimal
88
+ * distributions) that predicate is rejected, the refusal lands on stderr, and the empty stdout
89
+ * parses as "no files match" — a silent empty answer, the one outcome a listing must never
90
+ * produce. What the vector asks for instead is the pair every `find` carries: `-print0` (the
91
+ * NUL-terminated listing) and `-exec … ;` (busybox's `FEATURE_FIND_EXEC` and
92
+ * `FEATURE_FIND_PRINT0` are both default-on), with the distribution's own `stat` printing the
93
+ * mtime in front of each path. That is ONE spawn, not two — find runs the stat itself, per
94
+ * match, so the two halves of a record always describe the same hit.
95
+ *
96
+ * Why `stat` and not a shell loop: this module has no shell by construction (see the module
97
+ * docblock), so `stat` has to be executed BY find. Why the bare name: no absolute spelling is
98
+ * portable — coreutils ships `/usr/bin/stat` (Debian, Fedora) while busybox installs its applet
99
+ * at `/bin/stat` (Alpine) — so a hardcoded path would break exactly the distributions this
100
+ * change exists for; `-exec stat` is resolved by find's own `execvp` against the distribution's
101
+ * PATH.
102
+ *
103
+ * The parenthesised `-o -print0` is NOT decoration, it is MEASURED defence: `-exec stat … ;`
104
+ * and a following `-print0` are joined by an implicit AND, so when `stat` cannot be run the
105
+ * `-print0` is never reached — and GNU find reports that on stderr while still exiting **0**
106
+ * (measured: exit 0, empty stdout, two `<find>: 'stat': No such file or directory` lines). A
107
+ * reader that takes stdout alone would see the SAME silent empty listing this change exists to
108
+ * remove, one layer down. With the fallback group the paths are printed either way: the mtime
109
+ * first when `stat` answers, bare NUL-framed paths when it does not — and a bare path is not a
110
+ * readable record (`stat -c` always ends its line, so the parser needs that newline), which
111
+ * leaves a NON-EMPTY print that parses to NO path. That is exactly the shape `search-tool.js`'s
112
+ * own damage guard already raises on ("its output could not be read as path records"), so a
113
+ * `stat`-less distribution fails LOUDLY through the existing boundary instead of answering an
114
+ * empty listing. The group also means one unreadable file (a `stat` that fails per hit) only
115
+ * costs that record, not the whole listing.
116
+ *
117
+ * `%.9Y` is the mtime in seconds: GNU coreutils answers fractional seconds (what `%T@` gave the
118
+ * sort), busybox's format engine pads the same integer to nine digits instead, which parses back
119
+ * to the identical whole second — so sub-second precision degrades on busybox and the ORDER, the
120
+ * documented contract, does not. `-print0` rather than `-print` keeps the path in a NUL-framed
121
+ * record, so a path containing a newline survives; `stat -c` always terminates its own line, and
122
+ * the parser splits on that FIRST newline (a path may contain more).
85
123
  * @param {{ pattern: string, path: string }} input - the model's request.
86
124
  * @param {{ maxResults: number }} caps - the caller's cap; not consumed by the vector
87
125
  * (the cap is applied where the output is parsed, not on the command line).
@@ -106,7 +144,17 @@ export function globArgv(input, caps) {
106
144
  const argv = ['/usr/bin/find', '--', input.path]
107
145
  for (const name of VCS_EXCLUDES) argv.push('-name', name, '-prune', '-o')
108
146
  // -path matches the whole printed path, so the model's root-relative pattern is anchored here.
109
- argv.push('-path', `${root}/${translateGlob(input.pattern)}`, '-type', 'f', '-printf', '%T@ %p\\0')
147
+ // The mtime rides on -exec (see the docblock): it prints BEFORE the path, and its own newline is
148
+ // the record's field separator — MEASURED on GNU find 4.10.0 + coreutils stat, where the output
149
+ // per hit is `<mtime>\n<path>\0`. `;` and not `+`: with `+` find batches the names and runs stat
150
+ // at flush points, so the printed mtimes would no longer line up with the paths -print0 streams
151
+ // out; one stat per hit keeps the two halves interleaved. The trailing `-o -print0` is the
152
+ // measured fallback for a distribution whose `stat` is missing (see the docblock): it keeps the
153
+ // listing NON-EMPTY so the stat-less case fails loudly at the tool's damage guard instead of
154
+ // silently answering "no files match". The parentheses are REQUIRED — find's implicit AND binds
155
+ // tighter than `-o`, so without them the fallback would not attach to the -path/-type test.
156
+ argv.push('-path', `${root}/${translateGlob(input.pattern)}`, '-type', 'f',
157
+ '(', '-exec', 'stat', '-c', '%.9Y', '{}', ';', '-print0', '-o', '-print0', ')')
110
158
  return argv
111
159
  }
112
160
 
@@ -208,25 +256,28 @@ export function parseGrepOutput(stdout, caps) {
208
256
  }
209
257
 
210
258
  /**
211
- * Parse `find -printf '%T@ %p\0'` and order by mtime ASCENDING, in Node (no shell sort).
259
+ * Parse the glob listing `<mtime>\n<path>\0` and order by mtime ASCENDING, in Node (no shell sort).
212
260
  *
213
261
  * The record format is the vector's, and both halves of it are load-bearing: the mtime ends at
214
- * the FIRST space (a path may contain one), and the NUL is the separator because a path may
215
- * contain a newline — a line-based record would split one path in two. The order is the host's
216
- * MEASURED one rather than the natural-looking one: the host sorts by mtime ascending (oldest
217
- * first), so a descender here would be a silent disagreement with it. The cap is applied AFTER
218
- * the sort, which is what makes "the first N" mean the N oldest rather than whichever N find
219
- * happened to print first.
262
+ * the FIRST newline and the NUL is the record separator, because a path may contain both a
263
+ * newline and a space — a line-based record would split one path in two, and a space-based
264
+ * field split would cut one at its first space. `stat -c` always terminates its line, so the
265
+ * newline is the field boundary; the path starts right after it and runs to the NUL. The order
266
+ * is the host's MEASURED one rather than the natural-looking one: the host sorts by mtime
267
+ * ascending (oldest first), so a descender here would be a silent disagreement with it. The cap
268
+ * is applied AFTER the sort, which is what makes "the first N" mean the N oldest rather than
269
+ * whichever N find happened to print first.
220
270
  *
221
271
  * A record that cannot be read at all is DROPPED, symmetrically with the grep side (which drops a
222
272
  * line without a second colon, and one whose line-number field is not a finite integer — the two
223
- * tests differ only because the fields do: a count, an mtime). The vector's own `-printf` always
224
- * emits the space, so a record without one means the print was cut short, and a non-finite mtime
225
- * means a value this parser cannot order by — a phantom path, or an order that depends on where a
226
- * NaN landed in V8's sort, would be an answer nobody decided. The drop is deliberately silent
227
- * (`truncated` is about the CAP, not about damage): for a broken record, no path is the better
228
- * answer.
229
- * @param {string} stdout - find's stdout, `%T@ %p` records separated by NUL.
273
+ * tests differ only because the fields do: a count, an mtime). The vector's own `stat` always
274
+ * emits the newline, so a record without one means the print was cut short (or `stat` failed for
275
+ * that hit — find reports that on stderr and exits nonzero, which the tool raises), and a
276
+ * non-finite mtime means a value this parser cannot order by — a phantom path, or an order that
277
+ * depends on where a NaN landed in V8's sort, would be an answer nobody decided. The drop is
278
+ * deliberately silent (`truncated` is about the CAP, not about damage): for a broken record, no
279
+ * path is the better answer.
280
+ * @param {string} stdout - find's stdout, `<mtime>\n<path>` records separated by NUL.
230
281
  * @param {{ maxResults: number }} caps - the caller's cap.
231
282
  * @returns {{ paths: string[], truncated: boolean }} the paths, oldest first, and whether the CAP
232
283
  * left at least one further record out. (The caller's `root` is the tool's own: it fills it
@@ -235,13 +286,14 @@ export function parseGrepOutput(stdout, caps) {
235
286
  export function parseGlobOutput(stdout, caps) {
236
287
  const entries = stdout.split('\0').filter(entry => entry !== '')
237
288
  .map((entry) => {
238
- const space = entry.indexOf(' ')
239
- // No space means this is not a record the vector could have printed: skip it (never a path
240
- // built out of the damage). Not seen: a finite-looking prefix — a record cut before its space
241
- // — would pass the mtime check below, so the space itself is what says the record is whole.
242
- if (space === -1) return null
243
- const mtime = Number(entry.slice(0, space))
244
- return Number.isFinite(mtime) ? { mtime, path: entry.slice(space + 1) } : null
289
+ const newline = entry.indexOf('\n')
290
+ // No newline means this is not a record the vector could have printed: skip it (never a path
291
+ // built out of the damage). Not seen: a finite-looking prefix — a record cut before its
292
+ // newline — would pass the mtime check below, so the newline itself is what says the record
293
+ // is whole.
294
+ if (newline === -1) return null
295
+ const mtime = Number(entry.slice(0, newline))
296
+ return Number.isFinite(mtime) ? { mtime, path: entry.slice(newline + 1) } : null
245
297
  })
246
298
  .filter(entry => entry !== null)
247
299
  .sort((a, b) => a.mtime - b.mtime)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",