dsh-plugin-wsl-env 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +417 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +84 -68
- package/SUPPORT.md +3 -3
- package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
- package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
- package/agent/wsl-agent.sh +83 -16
- package/cordis.patch.yml +41 -35
- package/docs/ARCHITECTURE.md +9 -1
- package/docs/CONFIGURATION.md +7 -4
- package/docs/LIMITATIONS.md +53 -18
- package/docs/PARITY.md +3 -1
- package/docs/PEER-PARITY.md +18 -11
- package/docs/README.zh.md +97 -74
- package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
- package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
- package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
- package/lib/agent-protocol.d.ts +10 -4
- package/lib/agent-protocol.js +10 -3
- package/lib/agent.d.ts +84 -0
- package/lib/agent.js +167 -15
- package/lib/bwrap.d.ts +8 -0
- package/lib/bwrap.js +4 -1
- package/lib/fs-routing.d.ts +40 -4
- package/lib/fs-routing.js +80 -12
- package/lib/fsio-agent.js +1 -1
- package/lib/index.d.ts +134 -0
- package/lib/index.js +191 -3
- package/lib/native-agent.d.ts +124 -0
- package/lib/native-agent.js +198 -0
- package/lib/ports.d.ts +13 -2
- package/lib/ports.js +18 -5
- package/lib/preflight.js +24 -1
- package/lib/session-runtime.d.ts +161 -0
- package/lib/session-runtime.js +220 -0
- package/lib/session-scope.d.ts +124 -0
- package/lib/session-scope.js +191 -0
- package/lib/shell-env.js +10 -3
- package/lib/watcher.d.ts +25 -12
- package/lib/watcher.js +120 -32
- package/lib/workspace-files-route.d.ts +8 -4
- package/lib/workspace-files-route.js +42 -14
- package/lib/workspace-files-wsl.js +25 -8
- package/package.json +34 -25
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/dsh-plugin-wsl-env)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
This plugin lets [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
|
|
9
|
+
This plugin lets [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) work inside a WSL distro. The model's commands run there. Its file tools read and write the real distro files. The folder picker can open a distro folder, and the GUI terminal opens a shell inside it.
|
|
10
10
|
|
|
11
|
-
**Windows + WSL2 only.** One install command, no dependencies of its own. The environment
|
|
11
|
+
**Windows + WSL2 only.** One install command, no dependencies of its own. The environment is chosen per session: a session on a Windows folder keeps the normal Windows tools.
|
|
12
12
|
|
|
13
|
-
[Install](#install) · [Using it](#using-it) · [Configure](#configure) · [Recipes](#recipes) · [Architecture](#architecture) · [Sandbox](#sandbox) ·
|
|
13
|
+
[Install](#install) · [Using it](#using-it) · [How it compares](#how-it-compares) · [Configure](#configure) · [Recipes](#recipes) · [Architecture](#architecture) · [Sandbox](#sandbox) ·
|
|
14
14
|
[Troubleshooting](#troubleshooting) · [Development](#development) · [Documentation](#documentation)
|
|
15
15
|
|
|
16
16
|
## Install
|
|
@@ -18,77 +18,90 @@ This plugin lets [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harn
|
|
|
18
18
|
Run these four commands in a Windows terminal:
|
|
19
19
|
|
|
20
20
|
```powershell
|
|
21
|
-
dsh wsl --from-default-profile web --dump-config # 1. create
|
|
22
|
-
dsh plugin --profile wsl add dsh-plugin-wsl-env # 2. install the
|
|
23
|
-
dsh --profile wsl --dump-config # 3.
|
|
21
|
+
dsh wsl --from-default-profile web --dump-config # 1. create a profile (a named set of dsh settings) from the Web template
|
|
22
|
+
dsh plugin --profile wsl add dsh-plugin-wsl-env # 2. install the plugin and its settings
|
|
23
|
+
dsh --profile wsl --dump-config # 3. a quick check: see the merged settings without starting the app
|
|
24
24
|
dsh --profile wsl # 4. run
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Step 3 should print a
|
|
27
|
+
Step 3 should print a line `# == dsh-plugin-wsl-env`. That line is the plugin's settings arriving in your profile. The plugin ships its own settings, so step 2 wires everything in automatically — there is nothing to merge by hand.
|
|
28
28
|
|
|
29
|
-
**Before the first command, give the distro bubblewrap.** Every command runs
|
|
29
|
+
**Before the first command, give the distro bubblewrap.** Every command runs inside a bubblewrap sandbox, and most distros do not have bubblewrap preinstalled. Without it, every command fails instead of running unsandboxed:
|
|
30
30
|
|
|
31
31
|
```powershell
|
|
32
32
|
wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap # Debian/Ubuntu
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
From a checkout
|
|
35
|
+
From a plugin checkout, `pnpm run bootstrap -- <distro>` checks the four tools the plugin uses (bubblewrap, ripgrep, git, inotify-tools) and prints the install command for the distro's package manager; `--install` runs it. If this step is skipped, the session reports the missing package with the exact install command when it opens.
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
**The shipped preset also needs systemd.** Commands run in one systemd user scope per session (background dev servers survive, `/tmp` is shared, `ps` sees the session). Set `[boot] systemd=true` in `/etc/wsl.conf`, restart WSL, and enable lingering once: `loginctl enable-linger "$USER"`. The session refuses with those remedies if any piece is missing; `sessionSandbox: false` on the preset's `wsl-shell` restores the per-command sandbox.
|
|
38
|
+
|
|
39
|
+
Then open a folder like `\\wsl.localhost\<distro>\...` in the GUI. The folder picker lists every installed distro, and **New terminal** opens a shell inside the distro.
|
|
38
40
|
|
|
39
41
|
Uninstall: `dsh plugin --profile wsl remove dsh-plugin-wsl-env`. Upgrade: run the same `add` command again.
|
|
40
42
|
|
|
41
|
-
> **Changed anything under `lib/`? Sync the
|
|
43
|
+
> **Changed anything under `lib/`? Sync the Windows-side copy, then restart the app.** The app loads the Windows-side copy of the code, so run `pnpm run sync:windows` first. A restart without the sync just reloads the old code, and a running process also caches modules. See [Development](#development).
|
|
42
44
|
|
|
43
45
|
## Using it
|
|
44
46
|
|
|
45
|
-
Open `\\wsl.localhost\ubuntu\home\you\project`
|
|
47
|
+
Open a distro folder as the workspace — for example `\\wsl.localhost\ubuntu\home\you\project` — and ask the model "what kernel am I on, and what is in `/etc/os-release`?". It runs `uname -r` and reads that file inside the distro. Nothing is copied, and nothing crosses `/mnt/c`.
|
|
48
|
+
|
|
49
|
+
- **A distro folder gets the distro environment.** When a session opens a distro folder, it is automatically given the WSL environment. The very first command is already right.
|
|
50
|
+
- **Commands run where you expect.** They run inside the distro, in your own login shell — so your `PATH`, `nvm`, `cargo`, `pyenv` and rc files all apply. The shell comes from the distro's configuration; it is not hardcoded to bash.
|
|
51
|
+
- **Files are the real files.** `/home/you/x` and `\\wsl.localhost\ubuntu\home\you\x` are the same file. `/mnt/c/...` reaches the Windows disk as usual.
|
|
52
|
+
- **The terminal too.** Right sidebar, *New terminal*: a shell inside the distro, in the session's folder.
|
|
53
|
+
- **Port visibility.** The model sees which ports are listening inside the distro (refreshed about every 10 seconds) and can return the exact URL of a dev server it starts. WSL2 forwards localhost to Windows, so the URL opens in the browser directly.
|
|
54
|
+
- **Permissions work like on a Linux host.** The Permissions selector offers `read-only`, `workspace-write` (the default) and `danger-full-access`. When something is refused, the model is offered one retry with the smallest permission that would work. Only `danger-full-access` runs without the sandbox.
|
|
55
|
+
|
|
56
|
+
## How it compares
|
|
57
|
+
|
|
58
|
+
Three ways for a Windows coding tool to work on a WSL project:
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
1. **Install the tool inside WSL.** Codex CLI, Claude Code and ZCode CLI all recommend this, and they are right — it is the simplest path when a CLI tool covers your work. It needs the whole tool to live inside WSL. DeepSeek Harness cannot, so it needs one of the next two.
|
|
61
|
+
2. **Connect from a desktop app.** VS Code (Remote-WSL, and its open-source mirror [open-remote-wsl](https://github.com/jeanp413/open-remote-wsl)) and agent desktops like [ZCode](https://github.com/zai-org/ZCode) keep the app on Windows and set up a helper server inside the distro. Battle-tested. The cost: a server folder (`~/.vscode-server`, `~/.zcode/server`), a port, and no sandbox for commands.
|
|
62
|
+
3. **This plugin.** The harness stays on Windows. Nothing is installed in the distro, no port is opened, and commands run inside a bubblewrap sandbox. If something is missing, the session says what to install the moment it opens.
|
|
63
|
+
|
|
64
|
+
| | Option 2 (desktop remote) | Option 3 (this plugin) |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| installed in the distro | a server folder | nothing |
|
|
67
|
+
| opens a port | yes | no |
|
|
68
|
+
| command sandbox | none | bubblewrap + systemd session scope |
|
|
69
|
+
| after uninstall | remove the server manually | nothing left |
|
|
70
|
+
| after a WSL upgrade | server may need repair | the next command recovers automatically |
|
|
71
|
+
|
|
72
|
+
**Where the others win.** A desktop remote re-opens a workspace instantly — its server stays warm — and VS Code's remote has years of hardening behind it. A CLI inside WSL needs none of this. This plugin is for one case: DeepSeek Harness on Windows, working on WSL projects, with commands sandboxed.
|
|
54
73
|
|
|
55
74
|
## Configure
|
|
56
75
|
|
|
57
|
-
|
|
76
|
+
Each setting lives on a named row. To change one, add a row with the same id to `$DSH_HOME/profiles/<name>/cordis.patch.yml` (`$DSH_HOME` is DSH's settings folder). The keys worth knowing:
|
|
58
77
|
|
|
59
78
|
| Row | Key | Default | Meaning |
|
|
60
79
|
|---|---|---|---|
|
|
61
|
-
| `wsl-shell` | `distro` | `''` | distro
|
|
62
|
-
| | `sandbox` | `true` |
|
|
63
|
-
| `
|
|
64
|
-
| | `
|
|
65
|
-
|
|
|
66
|
-
| | `
|
|
67
|
-
|
|
|
80
|
+
| `wsl-shell` | `distro` | `''` | which distro to use; empty means WSL's default distro |
|
|
81
|
+
| | `sandbox` | `true` | run commands inside the `bubblewrap` sandbox; `false` turns it off |
|
|
82
|
+
| | `sessionSandbox` | `true` | run the session's commands in one persistent systemd user scope: background dev servers survive, `/tmp` is shared, `ps` sees the session. Needs systemd as PID 1, a reachable user manager and `Linger`; the preflight refuses with the remedy. `false` restores the per-command sandbox |
|
|
83
|
+
| | `agentBackend` | `auto` | the session runtime's agent: `auto` uses the native in-distro agent when a verified source exists (packaged static-musl binary at `agent/native/linux-<arch>/dsh-wsl-agent` (x86_64/aarch64) or the `DSH_WSL_AGENT_NATIVE` override), else the shell agent; `native` refuses instead of falling back; `shell` forces the shell agent. Covers commands and confined writes. The native backend is the default; the shell agent is the fallback |
|
|
84
|
+
| `wsl-fs` | `distro` | `''` | same as above |
|
|
85
|
+
| | `restrictToDistro` | `true` | refuse paths that belong to a **different** distro. (`/mnt/c` belongs to this distro, so it is not affected.) The refusal is `FS_OUTSIDE_DISTRO`; wider permissions cannot lift it |
|
|
86
|
+
| | `sandbox` | `true` | check file writes against the same policy |
|
|
87
|
+
| | `sessionSandbox` | `true` | run confined file writes in the same session runtime as commands (one real `/tmp`, one workspace mount view); needs the same systemd/linger setup; `false` uses the per-policy confined resident |
|
|
88
|
+
| | `substrate` | `agent` | how file operations reach the distro. Only one value remains: the in-distro agent, so reads and writes run on the distro's own filesystem, with native symlinks and permissions. The former `"share"` option used the 9p share and is rejected at startup |
|
|
89
|
+
| `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory in the picker |
|
|
68
90
|
| `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
|
|
69
91
|
|
|
70
|
-
`wsl-shell` and `wsl-fs`
|
|
92
|
+
`wsl-shell` and `wsl-fs` cannot be reached by adding `- id: wsl-shell` to your override file: they live inside the `wsl` preset's own settings (a preset is a named environment DSH can give a session), so the loader only warns `patch: entry "wsl-shell" not found` and the shipped value stays in force. To change them, copy and override the whole `preset-wsl` row — [docs/CONFIGURATION.md](docs/CONFIGURATION.md#overriding-the-rows-inside-preset-wsl) has the recipe.
|
|
71
93
|
|
|
72
|
-
[`cordis.patch.yml`](cordis.patch.yml) is the commented reference for every
|
|
94
|
+
[`cordis.patch.yml`](cordis.patch.yml) is the commented reference for every value the plugin ships. [docs/CONFIGURATION.md](docs/CONFIGURATION.md) lists the rest — `shell`, `loginShell`, `cwd`, `timeoutMs`, `preferredDistro`, `maxEntries` — and [examples/profile.cordis.patch.yml](examples/profile.cordis.patch.yml) is a settings file you can copy and edit.
|
|
73
95
|
|
|
74
96
|
## Recipes
|
|
75
97
|
|
|
76
|
-
- **
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- **Paths and performance**: the model sees and operates on Linux paths
|
|
80
|
-
(`/home/...`) on the distro's own ext4. `/mnt/c` reaches the Windows disk over
|
|
81
|
-
9p — noticeably slow for many small files; keep heavy-IO projects on the
|
|
82
|
-
distro filesystem. `pnpm run bootstrap -- <distro>` also reports whether
|
|
83
|
-
ripgrep, git and inotifywait (the search, snapshot and watch backends) are in place.
|
|
84
|
-
- **WSLENV passthrough**: WSL imports only the variables listed in `WSLENV`.
|
|
85
|
-
This plugin admits the managed `DSH_*` namespace by prefix, translating the
|
|
86
|
-
two Windows-path ones (`DSH_HOME`, `DSH_PROFILE_DIR`) with `/p`. `PATH` is
|
|
87
|
-
deliberately never forwarded — it would shadow the distro's own PATH.
|
|
98
|
+
- **Use your Windows git credentials inside the distro**: `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"` (adjust the path to where your Windows Git is installed). After this, `git push` inside the distro uses the same saved credentials as Windows.
|
|
99
|
+
- **Keep projects on the distro's own disk.** The model works on Linux paths (`/home/...`) on the distro's own disk, which is fast. It can still reach Windows files through `/mnt/c`, but that bridge is slow when many small files are involved. `pnpm run bootstrap -- <distro>` tells you whether ripgrep, git and inotifywait — used for search and file watching — are installed.
|
|
100
|
+
- **Environment variables**: WSL only forwards the variables named in `WSLENV`. This plugin forwards its own `DSH_*` values, and translates the two that hold Windows paths (`DSH_HOME`, `DSH_PROFILE_DIR`) into Linux paths. Your `PATH` is never forwarded — forwarding it would hide the distro's own PATH.
|
|
88
101
|
|
|
89
102
|
## Architecture
|
|
90
103
|
|
|
91
|
-
One DSH process
|
|
104
|
+
One DSH process can serve both kinds of session at the same time: a session on a Windows folder, and a session inside the distro. The plugin's parts attach at two levels, because some things belong to one session and some belong to the whole app:
|
|
92
105
|
|
|
93
106
|
```text
|
|
94
107
|
composition (app level, one per process)
|
|
@@ -102,28 +115,30 @@ composition (app level, one per process)
|
|
|
102
115
|
├─ wsl-shell-env the DSH_WSL_DISTRO / _SHELL / _HOME / _PORTS facts the model sees
|
|
103
116
|
└─ auto-preset binds the wsl preset when a session opens a distro folder
|
|
104
117
|
|
|
105
|
-
preset-wsl (the wsl agent preset; its
|
|
118
|
+
preset-wsl (the wsl agent preset; its settings are separate from the app's)
|
|
106
119
|
├─ wsl-shell ctx.shell — wsl.exe --exec <login shell>, confined by bubblewrap inside the distro
|
|
107
120
|
└─ wsl-fs ctx.fs — real distro files on the resident agent substrate (ext4)
|
|
108
121
|
```
|
|
109
122
|
|
|
110
|
-
**Why two levels.** `wsl-shell` and `wsl-fs
|
|
123
|
+
**Why two levels.** The `wsl` preset holds `wsl-shell` and `wsl-fs`, the two things that differ per session. When a session opens a distro folder, `auto-preset` gives that session the preset; a Windows-folder session keeps the normal Windows tools. One environment per session, in one process. The GUI terminal is the exception: it reads its shell from the app level, which never sees a preset, so `subprocess-wsl` is mounted at the app level. The GUI file tree and the root filesystem have the same shape of problem — every session uses them, so no single preset can own them — and `workspace-files-wsl` and `fs-routing` sit at the app level for that reason. They replace two shipped rows; [docs/root-fs-routing.md](docs/root-fs-routing.md) has the routing design.
|
|
124
|
+
|
|
125
|
+
**How a command runs.** A session gets one resident agent inside a systemd user scope. By default that agent is the native `dsh-wsl-agent` — the packaged static-musl binary for the distro's architecture, selected by `agentBackend: auto`; the runtime's shell agent is the fallback when the binary is missing or fails its copy/digest/exec check, and `agentBackend: shell` forces it while `native` refuses instead of falling back. The agent executes each command in your login shell under a bubblewrap mount profile. Because the scope outlives the command, a background dev server survives, `/tmp` is the distro's real `/tmp`, and `ps` sees the whole session. With `sessionSandbox: false`, the agent stays per distro, each command gets its own bwrap profile (`--unshare-pid`, a fresh `/tmp`), background processes do not survive, and the native backend is not used. If the agent is unavailable (or you set `agent: false`), the one-shot `wsl.exe` path runs the command instead — same result, slightly slower, and per-command isolation.
|
|
111
126
|
|
|
112
|
-
**
|
|
127
|
+
**How file operations run.** Reads, writes and searches also happen inside the distro, on its own filesystem, with the distro's own tools. A search over a distro workspace runs the distro's `rg`, never the Windows binary over the 9p share. File writes are checked against the same policy the command sandbox enforces; with `wsl-fs` `sessionSandbox` on (the shipped preset), a confined write runs in the same session runtime as the commands — the same native/shell agent — so it sees the same `/tmp` and workspace mounts. Reads use the shared shell agent.
|
|
113
128
|
|
|
114
129
|
## Sandbox
|
|
115
130
|
|
|
116
|
-
Commands
|
|
131
|
+
Commands run inside a `bubblewrap` sandbox in the distro, and file writes are checked against the same policy. The Windows sandbox cannot be used here: its restricted account cannot reach WSL at all.
|
|
117
132
|
|
|
118
133
|
| Mode | What a command inside the distro can do |
|
|
119
134
|
|---|---|
|
|
120
|
-
| `read-only` | read the whole distro
|
|
121
|
-
| `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a
|
|
122
|
-
| `danger-full-access` | no sandbox; used
|
|
135
|
+
| `read-only` | read the whole distro. A fresh `/dev` is mounted so `/dev/null` and `/dev/shm` work; nothing else is writable |
|
|
136
|
+
| `workspace-write` | all of the above, plus the session workspace is writable and `/tmp` is the distro's real `/tmp` (with `sessionSandbox: false`, the per-command profile mounts a fresh tmpfs there instead) |
|
|
137
|
+
| `danger-full-access` | no sandbox; used when you approve a wider-permission request |
|
|
123
138
|
|
|
124
|
-
|
|
139
|
+
**bubblewrap is required, and the plugin fails closed.** Without it, every confined command reports `SANDBOX_UNAVAILABLE` — the command does not run unsandboxed. To turn the sandbox off, set `sandbox: false` on the row that owns the operation: `wsl-shell` for commands, `wsl-fs` for file writes, and the top-level `fs-routing` row for writes to the app's own filesystem. (The two preset rows are reached through `preset-wsl` — see [Configure](#configure).) The model is then told that these operations have no sandbox.
|
|
125
140
|
|
|
126
|
-
**
|
|
141
|
+
**Known limits.** A command inside the distro can still start a Windows program (anything under `/mnt/c/.../*.exe`), and bubblewrap does not watch Windows programs. `pnpm run probe:sandbox` demonstrates this boundary on your machine. Setting `maskWindowsDrive: true` on the three rows narrows it: `/mnt` disappears from the command's view, so the drive's files cannot be read and its programs cannot be started. It does not close the hole completely — a command could still copy a Windows program into the workspace and run it — so the report stays `partial`. The only complete fix is in the distro itself: `[interop] enabled=false` in `wsl.conf` (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md)).
|
|
127
142
|
|
|
128
143
|
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [docs/LIMITATIONS.md](docs/LIMITATIONS.md) for everything the plugin does not do.
|
|
129
144
|
|
|
@@ -131,23 +146,23 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [do
|
|
|
131
146
|
|
|
132
147
|
| Symptom | Cause | Fix |
|
|
133
148
|
|---|---|---|
|
|
134
|
-
| every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is missing from the distro, or present but
|
|
135
|
-
| a command or write is refused outside the session folder | expected
|
|
149
|
+
| every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is missing from the distro, or present but broken — the error text tells you which one it is and what to do | follow the fix in the error, or set `sandbox: false` on `wsl-shell` (commands) and `wsl-fs` (writes) — see [Configure](#configure) for how to reach those rows |
|
|
150
|
+
| a command or write is refused outside the session folder | expected: `workspace-write` only allows writes in the session folder | accept the wider-permission offer, or open the session on the folder you need |
|
|
136
151
|
| writes are refused even inside the workspace | the session is in `read-only` mode | switch the Permissions selector |
|
|
137
|
-
| `dsh plugin add` prints no confirmation that
|
|
138
|
-
| the terminal still opens `cmd.exe` | the `terminal-controller` row from the
|
|
139
|
-
| changes to `lib/` have no effect | the app
|
|
140
|
-
| `link:\\wsl.localhost\...` leaves a broken symlink | pnpm cannot link a UNC path | link a Windows path instead; developing inside the distro needs the
|
|
141
|
-
| `glob` and `grep` are slow |
|
|
142
|
-
| the terminal reports `unknown` activity | only while the
|
|
152
|
+
| `dsh plugin add` prints no confirmation that anything was added | the plugin is already installed, so there is nothing new to report — this is success, not failure | nothing to do: the settings are already in place, and `dsh --profile wsl --dump-config` still shows `# == dsh-plugin-wsl-env` |
|
|
153
|
+
| the terminal still opens `cmd.exe` | the `terminal-controller` row from the plugin's settings did not apply | check that `dsh --profile wsl --dump-config` shows `shell: { path: wsl.exe, name: WSL }` |
|
|
154
|
+
| changes to `lib/` have no effect | the app runs a Windows-side copy of the code, and a running process also caches modules | run `pnpm run sync:windows` to update the copy, then restart the app |
|
|
155
|
+
| `link:\\wsl.localhost\...` leaves a broken symlink | pnpm cannot link a UNC path | link a Windows path instead; developing inside the distro needs the Windows-side copy, see [Development](#development) |
|
|
156
|
+
| `glob` and `grep` are slow | searches inside the distro need `rg` installed in the distro; Windows-folder searches are unaffected | run `pnpm run bootstrap -- <distro> --install` (installs ripgrep); distro searches always run the distro's rg — they never cross the slow bridge |
|
|
157
|
+
| the terminal reports `unknown` activity | only while the in-distro agent is temporarily unavailable — distro terminals are watched from inside the distro (a `DSH_TERMINAL_ID` marker scanned in `/proc`: a shell alone is `idle`, a shell running anything is `busy`) | check the distro is running; idle terminals are closed automatically after the controller's idle timeout (2 h by default), and `terminalIdleReclaim: false` turns the auto-close off |
|
|
143
158
|
| a result names an `FS_*` code | the code says what refused it, and what clears it | see the error-code table in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#error-codes) |
|
|
144
159
|
|
|
145
160
|
## Development
|
|
146
161
|
|
|
147
162
|
```bash
|
|
148
|
-
pnpm run build # compile src/ into lib/ (the
|
|
163
|
+
pnpm run build # compile src/ into lib/ (the built files are committed; rebuild after editing src/)
|
|
149
164
|
pnpm test # style checks, syntax pass, build consistency, type check, and unit tests
|
|
150
|
-
pnpm run sync:windows #
|
|
165
|
+
pnpm run sync:windows # copy the checkout onto the Windows-side copy the app loads (after any lib/ change, then restart the app)
|
|
151
166
|
pnpm run test:coverage # the unit tests with coverage thresholds (Node 22.8+)
|
|
152
167
|
pnpm run diagnose # read-only diagnostic report for a bug issue: versions, tools, bwrap probe
|
|
153
168
|
pnpm run bootstrap -- <distro> # check the four distro tools; add --install to install what is missing
|
|
@@ -155,29 +170,30 @@ pnpm run probe:sandbox # measure inside the distro what bubblewrap does a
|
|
|
155
170
|
pnpm run probe # filesystem probe against a real distro (Windows + WSL only)
|
|
156
171
|
pnpm run probe:sandbox-shell # boot a real harness and drive the confined executor
|
|
157
172
|
pnpm run probe:terminal # open a PTY through the terminal provider
|
|
158
|
-
pnpm run probe:substrate # drive the agent filesystem
|
|
173
|
+
pnpm run probe:substrate # drive the agent filesystem over a real wsl.exe transport (from inside the distro)
|
|
159
174
|
pnpm run probe:watch # arm the in-distro watcher over a real directory (from inside the distro)
|
|
160
|
-
pnpm run probe:agent # the resident
|
|
161
|
-
pnpm run probe:exec # the agent-backed execution
|
|
175
|
+
pnpm run probe:agent # compare the resident path against the fallback path (from inside the distro)
|
|
176
|
+
pnpm run probe:exec # the agent-backed execution: timeout, kill, cwd failure (from inside the distro)
|
|
162
177
|
pnpm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
|
|
163
178
|
pnpm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
|
|
164
179
|
pnpm run probe:mode # which POSIX-mode facts survive the share (needs a Windows node and an installed harness; opens no profile)
|
|
165
180
|
pnpm run probe:sandbox-off # prove sandbox: false unconfines both providers (Windows + WSL only)
|
|
166
181
|
```
|
|
167
182
|
|
|
168
|
-
The app
|
|
183
|
+
The app runs a Windows-side copy of the code, not your checkout. After changing anything under `lib/`, run `pnpm run sync:windows` before restarting the app — otherwise the restart just loads the old code. `pnpm run build` regenerates `lib/` from `src/`; the built files are committed, and `pnpm test` checks that they match.
|
|
169
184
|
|
|
170
|
-
|
|
185
|
+
Run `pnpm install` once to get the development dependencies. The tests use the pinned `@deepseek-ai/*` packages; the plugin itself ships none. CI runs the tests on Node 22 and 24, on Linux and Windows, and coverage on Node 24.
|
|
171
186
|
|
|
172
|
-
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) has the file layout, the
|
|
187
|
+
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) has the file layout, how the parts attach, and the sandbox design. [CONTRIBUTING.md](CONTRIBUTING.md) has the development loop, the Windows-side copy, the full gate list, and what has been verified.
|
|
173
188
|
|
|
174
189
|
## Documentation
|
|
175
190
|
|
|
176
191
|
| Document | What it covers |
|
|
177
192
|
|---|---|
|
|
178
193
|
| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | every configuration key, and how to override it |
|
|
179
|
-
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
|
|
194
|
+
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | the plugin's parts and how they attach, path coordinates, the sandbox, testing, file layout |
|
|
180
195
|
| [docs/LIMITATIONS.md](docs/LIMITATIONS.md) | what the plugin does not do, and why |
|
|
196
|
+
| [docs/VALUE.zh.md](docs/VALUE.zh.md) | the plugin's value in Chinese: what it is worth, what it costs, and when not to use it |
|
|
181
197
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | the development loop, gates, conventions, verification |
|
|
182
198
|
| [SECURITY.md](SECURITY.md) | reporting a vulnerability privately |
|
|
183
199
|
| [SUPPORT.md](SUPPORT.md) | supported versions, and where to ask |
|
package/SUPPORT.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
| Area | Supported |
|
|
6
6
|
|---|---|
|
|
7
7
|
| Operating system | Windows 10 and Windows 11 |
|
|
8
|
-
| Linux side | WSL2 with at least one distro |
|
|
9
|
-
| DSH packages | the `0.2.
|
|
8
|
+
| Linux side | WSL2 with at least one distro, and systemd as PID 1 with a lingering user manager (`loginctl enable-linger`) for the shipped session runtime. Set `sessionSandbox: false` on the preset's `wsl-shell` to keep the per-command sandbox instead |
|
|
9
|
+
| DSH packages | the `0.2.1-rc.2`-era packages listed in `peerDependencies` |
|
|
10
10
|
| Plugin version | the latest published version only |
|
|
11
11
|
| Node | 22.19+ (`^22.19.0`), or 24 and later (`>=24.0.0`) |
|
|
12
12
|
|
|
@@ -50,7 +50,7 @@ issue for one.
|
|
|
50
50
|
## Compatibility expectations
|
|
51
51
|
|
|
52
52
|
The upstream packages are a release candidate, so the peer versions are pinned
|
|
53
|
-
exactly. One example is `@deepseek-ai/dsh-fs: 0.2.
|
|
53
|
+
exactly. One example is `@deepseek-ai/dsh-fs: 0.2.1-rc.2`. An upstream change can
|
|
54
54
|
require a new release of this plugin. The exact pins are what make that break
|
|
55
55
|
loudly instead of quietly.
|
|
56
56
|
|
|
Binary file
|
|
Binary file
|
package/agent/wsl-agent.sh
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
|
|
64
64
|
set -u
|
|
65
65
|
|
|
66
|
-
PROTO_VERSION=
|
|
66
|
+
PROTO_VERSION=5
|
|
67
67
|
TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
|
|
68
68
|
OUT_FILE="$TMPDIR_AGENT/out"
|
|
69
69
|
ERR_FILE="$TMPDIR_AGENT/err"
|
|
@@ -108,7 +108,7 @@ case ${DSH_AGENT_LEASE_MS:-} in
|
|
|
108
108
|
kill -0 "$$" 2>/dev/null || exit 0
|
|
109
109
|
[ -f "$TMPDIR_AGENT/inflight" ] || kill -s TERM "$$"
|
|
110
110
|
done
|
|
111
|
-
) &
|
|
111
|
+
) </dev/null >/dev/null 2>&1 &
|
|
112
112
|
WATCHDOG_PID=$!
|
|
113
113
|
fi ;;
|
|
114
114
|
esac
|
|
@@ -164,7 +164,22 @@ shutdown() {
|
|
|
164
164
|
kill_current
|
|
165
165
|
exit 0
|
|
166
166
|
}
|
|
167
|
+
|
|
168
|
+
# Cancellation arrives as USR1, not as a frame: while a request runs this loop
|
|
169
|
+
# is blocked in `wait`, so a KILL line would sit in the pipe until the command
|
|
170
|
+
# ended and be dropped by the time it was read. The host delivers the signal
|
|
171
|
+
# from a second wsl.exe; the trap runs inside the interrupted wait and kills
|
|
172
|
+
# the request's whole group, TERM first and KILL after the timeout watcher's
|
|
173
|
+
# grace, so a command that ignores TERM still cannot outlive the cancel.
|
|
174
|
+
cancel_current() {
|
|
175
|
+
[ -n "${CURRENT_PID:-}" ] || return 0
|
|
176
|
+
cancel_pid=$CURRENT_PID
|
|
177
|
+
kill_request TERM "$cancel_pid"
|
|
178
|
+
( sleep 3; kill_request KILL "$cancel_pid" ) </dev/null >/dev/null 2>&1 &
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
trap shutdown TERM INT HUP
|
|
182
|
+
trap cancel_current USR1
|
|
168
183
|
|
|
169
184
|
# Whether EXEC requests can run as their own session. `setsid` makes the
|
|
170
185
|
# request process a session AND process-group leader (pid = pgid), which is
|
|
@@ -199,6 +214,7 @@ timeout_watcher() {
|
|
|
199
214
|
# functions, not executables — run.
|
|
200
215
|
run_capture() {
|
|
201
216
|
local req_id timeout_s maxout mode rc watcher_pid out_cut err_cut
|
|
217
|
+
local out_fifo err_fifo out_reader err_reader spawn_out spawn_err killer_pid
|
|
202
218
|
req_id=$1
|
|
203
219
|
timeout_s=$2
|
|
204
220
|
maxout=$3
|
|
@@ -209,24 +225,57 @@ run_capture() {
|
|
|
209
225
|
esac
|
|
210
226
|
: >"$OUT_FILE"
|
|
211
227
|
: >"$ERR_FILE"
|
|
228
|
+
# With an output budget, capture through a FIFO drained by a bounded reader:
|
|
229
|
+
# a bounded number of 4 KiB blocks lands in the file, the rest is discarded,
|
|
230
|
+
# so a chatty command cannot fill the distro's /tmp. `dd` writes each block
|
|
231
|
+
# as it arrives (unlike `head`, whose buffered output is lost when the
|
|
232
|
+
# reader is stopped), and a block or two of slack is what tells "cut" from
|
|
233
|
+
# "exactly at the cap". A piped capture is still not a TTY, and the reader
|
|
234
|
+
# drains rather than closing the pipe, so the command does not see SIGPIPE.
|
|
235
|
+
# Without a budget (FS frames) the files are used directly, as before.
|
|
236
|
+
out_fifo=
|
|
237
|
+
err_fifo=
|
|
238
|
+
out_reader=
|
|
239
|
+
err_reader=
|
|
240
|
+
spawn_out=$OUT_FILE
|
|
241
|
+
spawn_err=$ERR_FILE
|
|
242
|
+
if [ "$maxout" -gt 0 ]; then
|
|
243
|
+
out_fifo="$TMPDIR_AGENT/out.fifo"
|
|
244
|
+
err_fifo="$TMPDIR_AGENT/err.fifo"
|
|
245
|
+
rm -f -- "$out_fifo" "$err_fifo"
|
|
246
|
+
if mkfifo -- "$out_fifo" "$err_fifo" 2>/dev/null; then
|
|
247
|
+
( dd bs=4096 count="$((maxout / 4096 + 2))" of="$OUT_FILE" 2>/dev/null; cat >/dev/null ) <"$out_fifo" 2>/dev/null &
|
|
248
|
+
out_reader=$!
|
|
249
|
+
( dd bs=4096 count="$((maxout / 4096 + 2))" of="$ERR_FILE" 2>/dev/null; cat >/dev/null ) <"$err_fifo" 2>/dev/null &
|
|
250
|
+
err_reader=$!
|
|
251
|
+
spawn_out=$out_fifo
|
|
252
|
+
spawn_err=$err_fifo
|
|
253
|
+
else
|
|
254
|
+
rm -f -- "$out_fifo" "$err_fifo"
|
|
255
|
+
fi
|
|
256
|
+
fi
|
|
212
257
|
CURRENT_ID=$req_id
|
|
213
258
|
# The inflight marker pauses the lease watchdog for this request and
|
|
214
259
|
# refreshes the temp dir's mtime for the boot sweep; removing it after the
|
|
215
260
|
# answer re-arms both.
|
|
216
261
|
: >"$TMPDIR_AGENT/inflight"
|
|
217
262
|
if [ "$mode" = subshell ]; then
|
|
218
|
-
( "$@" ) >"$
|
|
263
|
+
( "$@" ) >"$spawn_out" 2>"$spawn_err" &
|
|
219
264
|
elif [ -n "$SETSID" ]; then
|
|
220
|
-
( exec setsid "$@" ) >"$
|
|
265
|
+
( exec setsid "$@" ) >"$spawn_out" 2>"$spawn_err" &
|
|
221
266
|
else
|
|
222
|
-
"$@" >"$
|
|
267
|
+
"$@" >"$spawn_out" 2>"$spawn_err" &
|
|
223
268
|
fi
|
|
224
269
|
CURRENT_PID=$!
|
|
270
|
+
# Dispatched: the child exists now, so the host may arm its execution
|
|
271
|
+
# watchdog AND deliver USR1 to cancel it. Announcing earlier would race a
|
|
272
|
+
# signal against CURRENT_PID and lose the cancellation.
|
|
273
|
+
printf 'ACK|%s\n' "$req_id"
|
|
225
274
|
watcher_pid=
|
|
226
275
|
case $timeout_s in
|
|
227
276
|
''|0) ;;
|
|
228
277
|
*)
|
|
229
|
-
timeout_watcher "$timeout_s" "$CURRENT_PID" 3 &
|
|
278
|
+
timeout_watcher "$timeout_s" "$CURRENT_PID" 3 </dev/null >/dev/null 2>&1 &
|
|
230
279
|
watcher_pid=$!
|
|
231
280
|
;;
|
|
232
281
|
esac
|
|
@@ -238,11 +287,31 @@ run_capture() {
|
|
|
238
287
|
kill "$watcher_pid" 2>/dev/null
|
|
239
288
|
wait "$watcher_pid" 2>/dev/null
|
|
240
289
|
fi
|
|
290
|
+
# Wait for the bounded readers to drain what the request wrote, but not for
|
|
291
|
+
# a background descendant that still holds the FIFO: after a short grace the
|
|
292
|
+
# killer stops them. A normal request's readers see EOF when the command
|
|
293
|
+
# exits, so the wait returns at once and the killer is cancelled — this is
|
|
294
|
+
# also what gives a just-exited command's output a chance to reach the file
|
|
295
|
+
# before the reader is stopped.
|
|
296
|
+
if [ -n "$out_reader" ] || [ -n "$err_reader" ]; then
|
|
297
|
+
(
|
|
298
|
+
sleep 1
|
|
299
|
+
[ -z "$out_reader" ] || kill "$out_reader" 2>/dev/null
|
|
300
|
+
[ -z "$err_reader" ] || kill "$err_reader" 2>/dev/null
|
|
301
|
+
) </dev/null >/dev/null 2>&1 &
|
|
302
|
+
killer_pid=$!
|
|
303
|
+
[ -z "$out_reader" ] || wait "$out_reader" 2>/dev/null
|
|
304
|
+
[ -z "$err_reader" ] || wait "$err_reader" 2>/dev/null
|
|
305
|
+
kill "$killer_pid" 2>/dev/null
|
|
306
|
+
wait "$killer_pid" 2>/dev/null
|
|
307
|
+
fi
|
|
241
308
|
# The cap is what keeps a chatty command from becoming a multi-gigabyte
|
|
242
309
|
# base64 blob inside THIS shell's command substitution: only the first
|
|
243
310
|
# $maxout bytes of each stream are encoded, and the flags tell the host
|
|
244
|
-
# which streams were cut.
|
|
245
|
-
#
|
|
311
|
+
# which streams were cut. The bounded reader kept the file within a block or
|
|
312
|
+
# two of the budget, so the size check is still a sound cut signal. FS frames
|
|
313
|
+
# pass 0 — their payloads are the host's own read windows, already bounded by
|
|
314
|
+
# the protocol.
|
|
246
315
|
out_cut=0
|
|
247
316
|
err_cut=0
|
|
248
317
|
if [ "$maxout" -gt 0 ]; then
|
|
@@ -564,7 +633,7 @@ fs_dispatch() {
|
|
|
564
633
|
esac
|
|
565
634
|
}
|
|
566
635
|
|
|
567
|
-
printf 'HELLO|wsl-agent|%s|%s\n' "$PROTO_VERSION" "$AGENT_DIGEST"
|
|
636
|
+
printf 'HELLO|wsl-agent|%s|%s|%s\n' "$PROTO_VERSION" "$AGENT_DIGEST" "$$"
|
|
568
637
|
|
|
569
638
|
while IFS= read -r line; do
|
|
570
639
|
case $line in
|
|
@@ -636,9 +705,8 @@ while IFS= read -r line; do
|
|
|
636
705
|
printf 'ERR|%s|protocol|%s\n' "$req_id" "$(printf '%s' "an argv payload failed to decode" | base64 | tr -d '\n')"
|
|
637
706
|
continue
|
|
638
707
|
fi
|
|
639
|
-
# Dispatched:
|
|
640
|
-
#
|
|
641
|
-
printf 'ACK|%s\n' "$req_id"
|
|
708
|
+
# Dispatched: run_capture prints the ACK once the child exists, so the
|
|
709
|
+
# host's watchdog and USR1 cancel both target a real process.
|
|
642
710
|
run_capture "$req_id" "$timeout_s" "$maxout" exec "$@"
|
|
643
711
|
;;
|
|
644
712
|
FS\|*)
|
|
@@ -660,10 +728,9 @@ while IFS= read -r line; do
|
|
|
660
728
|
n=$((n + 1))
|
|
661
729
|
set -- "$@" "$arg_b64"
|
|
662
730
|
done
|
|
663
|
-
# Same dispatch signal as EXEC:
|
|
664
|
-
#
|
|
665
|
-
#
|
|
666
|
-
printf 'ACK|%s\n' "$req_id"
|
|
731
|
+
# Same dispatch signal as EXEC: run_capture ACKs after the child exists.
|
|
732
|
+
# No capture cap — the payloads are the host's own read windows, bounded
|
|
733
|
+
# by the protocol.
|
|
667
734
|
run_capture "$req_id" "$timeout_s" 0 subshell fs_dispatch "$op" "$@"
|
|
668
735
|
;;
|
|
669
736
|
*)
|