dsh-plugin-wsl-env 0.1.10 → 0.3.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 +268 -0
- package/README.md +21 -2
- package/agent/wsl-agent.sh +498 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +12 -1
- package/docs/LIMITATIONS.md +70 -5
- package/docs/PARITY.md +70 -0
- package/docs/PEER-PARITY.md +60 -0
- package/docs/README.zh.md +31 -20
- package/docs/UPSTREAM-FSIO-EXPORT.md +87 -0
- package/lib/agent-confined.js +81 -0
- package/lib/agent-errors.js +33 -0
- package/lib/agent-exec.js +204 -0
- package/lib/agent-protocol.js +174 -0
- package/lib/agent-shared.js +32 -0
- package/lib/agent.js +564 -0
- package/lib/bwrap.js +74 -0
- package/lib/fs-publish.js +42 -0
- package/lib/fs-substrate.js +285 -0
- package/lib/fsio-agent.js +481 -0
- package/lib/fsio-text.js +153 -0
- package/lib/index.js +430 -12
- package/lib/ports.js +61 -0
- package/lib/sandbox.js +35 -28
- package/lib/shell-env.js +28 -2
- package/lib/subprocess.js +15 -0
- package/lib/terminal-route.js +31 -0
- package/lib/watcher.js +127 -0
- package/package.json +13 -4
package/docs/LIMITATIONS.md
CHANGED
|
@@ -3,9 +3,69 @@
|
|
|
3
3
|
What the plugin does not do, and why. A limitation that is documented is a design
|
|
4
4
|
boundary; one that is not is a bug report waiting to happen.
|
|
5
5
|
|
|
6
|
+
## The filesystem substrate (`wsl-fs` `substrate`)
|
|
7
|
+
|
|
8
|
+
`substrate: "agent"` (the default) serves the file tools through the resident
|
|
9
|
+
in-distro agent, on ext4. `substrate: "share"` is the opt-out: the Windows-side
|
|
10
|
+
host stack over the distro's 9p share. What differs:
|
|
11
|
+
|
|
12
|
+
- **A new file publishes with mode 0600 on the agent substrate.** That is the
|
|
13
|
+
host backend's own POSIX publication semantics (a private 0600 staging file,
|
|
14
|
+
renamed into place), which a Linux-host DSH session has always had. The share
|
|
15
|
+
substrate was the outlier: the share drops mode sets, so new files took the
|
|
16
|
+
distro's umask default (0644). Overwrites and edits keep the original bits on
|
|
17
|
+
both substrates. Run `chmod` (or ask the model to) when a new file must be
|
|
18
|
+
executable.
|
|
19
|
+
- **There is no silent fallback to the share.** When the agent substrate's
|
|
20
|
+
resident process is out — the distro stopped, or the agent crashed past its
|
|
21
|
+
one rebuild — file operations fail with `FS_IO_ERROR` naming the substrate,
|
|
22
|
+
rather than quietly degrading to share semantics that cannot follow symlinks
|
|
23
|
+
or keep mode bits. The share substrate dies with the distro anyway; the
|
|
24
|
+
agent only adds its own failure mode, which is the one honest way to fail.
|
|
25
|
+
- **The mutation guard survives to the write.** `createIfAbsent` publishes with
|
|
26
|
+
a no-replace link inside the distro, which closes the check-then-write window
|
|
27
|
+
for creates outright. An overwrite or edit carries the version it was based
|
|
28
|
+
on into the write op, and the agent re-verifies it against a fresh stat one
|
|
29
|
+
syscall before the rename: a concurrent writer wins and the stale write
|
|
30
|
+
refuses with `FS_STALE_VERSION`. That narrows the share's window from a host
|
|
31
|
+
round trip to kernel-adjacent — the residual stat-then-rename pair is the
|
|
32
|
+
same sliver a Linux-host session's own rename has.
|
|
33
|
+
- **Stage two routes mutations through a confined resident.** A write or edit
|
|
34
|
+
under a confined policy runs on its own long-lived agent, spawned inside the
|
|
35
|
+
bwrap profile that binds exactly what the mode grants — so the kernel, not
|
|
36
|
+
just the host-side check, refuses a write outside the workspace, and the
|
|
37
|
+
refusal carries `FS_SANDBOX_DENIED` (the command path's dialect). Costs to
|
|
38
|
+
know: `bubblewrap` becomes required for confined writes (a distro without it
|
|
39
|
+
refuses them with the bootstrap command, rather than downgrading); a policy
|
|
40
|
+
change addresses a different resident, so the first write after switching the
|
|
41
|
+
Permissions selector pays one agent startup; and a write the policy grants to
|
|
42
|
+
the distro's `/tmp` lands in the confined mount's tmpfs, not the real `/tmp`
|
|
43
|
+
— the same hand-off rule as the commands' `/tmp`: pass files through the
|
|
44
|
+
session workspace.
|
|
45
|
+
- **`resolveSymlinks` is not consulted.** The share cannot traverse Linux
|
|
46
|
+
symlinks, so the share substrate retries missing reads through the distro's
|
|
47
|
+
canonical path; the agent substrate never misses them, so the config key has
|
|
48
|
+
no effect there.
|
|
49
|
+
- **`glob` and `grep` are unchanged on both substrates.** They are tool-layer
|
|
50
|
+
searches running the packaged Windows ripgrep against the share — correct
|
|
51
|
+
results, slow, and `.gitignore` interpreted with Windows rules. Bringing them
|
|
52
|
+
in-distro is future work, not part of the substrate switch.
|
|
53
|
+
- **`watch()` is the same poll loop on both substrates** (a `find -newer` scan
|
|
54
|
+
inside the distro), so the coarse-invalidation caveats below apply equally.
|
|
55
|
+
An inotify backend is planned, not shipped.
|
|
56
|
+
|
|
57
|
+
Everything below applies to the share substrate unless it says otherwise.
|
|
58
|
+
|
|
6
59
|
- **The sandbox does not govern WSL interop.** A confined command can still run a
|
|
7
60
|
Windows program, which escapes the Linux-side boundary. The plugin reports this as
|
|
8
61
|
`enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
|
|
62
|
+
The `maskWindowsDrive` key on `wsl-shell` and `wsl-fs` narrows the hole: the
|
|
63
|
+
confined profile shadows `/mnt` with an empty tmpfs, so the drive's files cannot be
|
|
64
|
+
read or exfiltrated and its executables cannot be launched. It is not a closure — a
|
|
65
|
+
command that can write the workspace can write an executable there and run it
|
|
66
|
+
(binfmt interop dispatches on file content, not location) — so enforcement stays
|
|
67
|
+
`partial`, the result's sandbox fact reports `windowsDrive: "masked"`, and the only
|
|
68
|
+
complete closure remains distro-level (`[interop] enabled=false` in `wsl.conf`).
|
|
9
69
|
- **`bubblewrap` must be installed**, or both providers fail immediately. The
|
|
10
70
|
usability probe re-runs after a failure, so installing bubblewrap while the app
|
|
11
71
|
runs is believed on the next command; only a success is cached.
|
|
@@ -31,13 +91,18 @@ boundary; one that is not is a bug report waiting to happen.
|
|
|
31
91
|
checks the caller's guard itself, because the Windows backend publishes a guarded
|
|
32
92
|
new file with a hard link, and the share does not support hard links. This is the
|
|
33
93
|
race that `dsh-fs-sandbox` already documents.
|
|
34
|
-
- `watch()`
|
|
35
|
-
|
|
94
|
+
- `watch()` observes from inside the distro (a `find -newer` poll loop, see
|
|
95
|
+
`lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
|
|
96
|
+
and they arrive on the poll cadence, not instantly. A file created with an mtime
|
|
97
|
+
older than the watcher's stamp (`cp -p`, `tar -x`) is not seen until something
|
|
98
|
+
else touches the tree. Watching a 9p share from the Windows side remains
|
|
99
|
+
unsupported — the loop runs where the kernel can actually report change.
|
|
36
100
|
- `glob` and `grep` use the Windows-side ripgrep over the share: correct results, but
|
|
37
101
|
not fast, and `.gitignore` is interpreted with Windows rules. `editText` reads the
|
|
38
102
|
whole file into memory before writing it back.
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
its
|
|
103
|
+
- Terminal routing is per-session by workspace coordinates (`subprocess-wsl.hostSessions`,
|
|
104
|
+
default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
|
|
105
|
+
`powershell.exe` in its own directory. The shell MENU remains the single configured
|
|
106
|
+
profile, and a directory-less launch still lands in the distro.
|
|
42
107
|
- Terminal activity reporting stops at `wsl.exe`, so the controller never reclaims
|
|
43
108
|
these terminals when they are idle.
|
package/docs/PARITY.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Parity with VS Code Remote-WSL
|
|
2
|
+
|
|
3
|
+
What parity means for this plugin, the seam contracts the plan was audited
|
|
4
|
+
against, and the phase checklist. Every phase below ends with rows of this
|
|
5
|
+
table ticked and this document updated in the same change.
|
|
6
|
+
|
|
7
|
+
## What parity means here
|
|
8
|
+
|
|
9
|
+
Remote-WSL's experience rests on one thing: a resident server inside the
|
|
10
|
+
distro. Everything users love — native ext4 semantics, inotify, native
|
|
11
|
+
`rg`, low latency — is a derivative. This plugin's 9p-UNC plus one-shot
|
|
12
|
+
`wsl.exe` model can only approach it. The plan therefore lands a resident
|
|
13
|
+
in-distro agent and migrates the `fs` and `shell` seams onto it, keeping the
|
|
14
|
+
current paths as the documented fallback (`wsl.agent: 'off'` must equal
|
|
15
|
+
today's behaviour byte for byte).
|
|
16
|
+
|
|
17
|
+
Not applicable, recorded so the gap is a decision and not an omission:
|
|
18
|
+
|
|
19
|
+
- IntelliSense, debuggers, the extension marketplace: editor features. The
|
|
20
|
+
DSH analogue is tool latency and correctness (Phases 1–3).
|
|
21
|
+
- Settings Sync, Remote-SSH tunnel chaining, Codespaces: composition-layer
|
|
22
|
+
responsibilities, not this plugin's.
|
|
23
|
+
|
|
24
|
+
## Contract audit (Phase 0)
|
|
25
|
+
|
|
26
|
+
Findings from reading the harness packages inside the app bundle; each one
|
|
27
|
+
shaped a phase of the plan.
|
|
28
|
+
|
|
29
|
+
1. **`ctx.fs.watch` is a coarse invalidation callback, not an event stream.**
|
|
30
|
+
`FileSystem.watch(target, changed, signal)` (dsh-fs) resolves once
|
|
31
|
+
observation is active and returns an async close function; `changed` takes
|
|
32
|
+
no event payload. So the in-distro watcher does not need to classify
|
|
33
|
+
rename vs. modify — any inotify (or polling) hit may call `changed()`.
|
|
34
|
+
Phase 1's watcher is accordingly simpler than planned.
|
|
35
|
+
2. **Search is not an `fs` seam.** `dsh-tool-fs-search` spawns the PACKAGED
|
|
36
|
+
ripgrep binary (`@vscode/ripgrep`) through `ctx.subprocess` with a plain
|
|
37
|
+
argv vector and no shell; raw stdout is parsed by the tool. The correct
|
|
38
|
+
interception point is therefore the plugin's `subprocess-wsl` subclass:
|
|
39
|
+
when `argv[0]` is the packaged rg binary and the spec's cwd is inside this
|
|
40
|
+
distro's share, rewrite the spawn to run a distro-side `rg` (falling back
|
|
41
|
+
to `grep -rE` + `find` when absent) with identical stdout conventions.
|
|
42
|
+
Under the agent, that exec rides the agent's `exec` capability.
|
|
43
|
+
3. **`dsh-fs-local` watches with chokidar over the share** — the behaviour
|
|
44
|
+
this plugin replaces: chokidar on 9p is the unreliability that made
|
|
45
|
+
`WslFileSystem.watch` refuse. The in-distro watcher replaces it wholesale.
|
|
46
|
+
4. **`dsh-bash-local` carries the timeout machinery** (`clampTimeout`,
|
|
47
|
+
`deadline`, `timeoutOf`, `BASH_TIMEOUT`), which the WSL executor already
|
|
48
|
+
inherits. Migrating exec onto the agent keeps that shape: the deadline
|
|
49
|
+
arms on the host as today, but the SIGTERM→SIGKILL grace fires inside the
|
|
50
|
+
distro via the agent instead of against the Windows process tree.
|
|
51
|
+
|
|
52
|
+
## Phase checklist
|
|
53
|
+
|
|
54
|
+
| Phase | Deliverable | Parity row |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| 0 | This document; contract audit above | baseline recorded |
|
|
57
|
+
| 1 | `lib/agent/` + `wsl-agent` resident process: NDJSON-over-stdio protocol, capability handshake, lazy start, idle shutdown, crash-rebuild-once, permanent fallback | resident server |
|
|
58
|
+
| 2 | real `watch()` SHIPPED (in-distro poll, `lib/watcher.js`); the native-ext4 migration of the remaining fs operations (and with it the workaround deletions) continues alongside Phase 3 | native file semantics, inotify — watch + publication done |
|
|
59
|
+
| 3 | DONE — `WslShellExecutor` exec via agent; in-distro TERM→KILL; handle shape preserved (deviation: no streaming, no spill files) | native exec latency |
|
|
60
|
+
| 4 | DONE (except the agent/plugin version handshake, which lands with the first agent-behaviour change after release) — `npm run bootstrap`, family-aware install command, `SANDBOX_UNAVAILABLE` names it | open-and-it-works |
|
|
61
|
+
| 5 | DONE (routing half) — per-session routing by workspace coordinates (`terminal-route.js`, `hostSessions`); activity-based idle reclaim of distro terminals remains a documented limitation (PTY activity stops at `wsl.exe`) | terminal per window/session — routing done |
|
|
62
|
+
| 6 | DONE — `DSH_WSL_PORTS` (agent-scanned `/proc/net/tcp{,6}`, timer-refreshed); localhost reachability (WSL2 localhost forwarding is platform behaviour) documented in README | port forwarding visibility |
|
|
63
|
+
| 7 | DONE — recipes section in both READMEs: credential sharing, `/mnt/c` guidance, `WSLENV` policy | developer-loop odds and ends |
|
|
64
|
+
|
|
65
|
+
## Fallback invariant
|
|
66
|
+
|
|
67
|
+
Every phase must keep `wsl.agent: 'off'` (and, per capability, any runtime
|
|
68
|
+
fallback) behaviourally identical to the pre-phase release. Probes assert
|
|
69
|
+
this: `agent.sh` runs the same operation with the agent off and on and
|
|
70
|
+
compares outcomes, not internals.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Peer parity — the fsio replication, verified
|
|
2
|
+
|
|
3
|
+
The agent substrate's text mechanics (`lib/fsio-text.js`) and its provider
|
|
4
|
+
orchestration messages (`lib/fs-substrate.js`, `lib/fsio-agent.js`) are a
|
|
5
|
+
deliberate replica of the pinned peer's fsio module, so the model reads the
|
|
6
|
+
same refusal dialect on both substrates. This file records the verification of
|
|
7
|
+
that claim against the real peers, which are not imported at build time.
|
|
8
|
+
|
|
9
|
+
- Peer: `@deepseek-ai/dsh-fs-local@0.2.0-rc.2` (the exact version
|
|
10
|
+
`package.json` pins), plus `@deepseek-ai/dsh-fs@0.2.0-rc.2` for the
|
|
11
|
+
`FsVersion` brand the local package imports.
|
|
12
|
+
- Method: `npm pack` both pins, extract, and compare the shipped
|
|
13
|
+
`lib/index.js` (unminified ESM) function-by-function and string-by-string
|
|
14
|
+
against this repository. Verified 2026-10-03.
|
|
15
|
+
|
|
16
|
+
## Verdict
|
|
17
|
+
|
|
18
|
+
**The replication is faithful.** Every load-bearing line matches the peer's
|
|
19
|
+
shipped code; the differences are the substrate's declared extensions, listed
|
|
20
|
+
at the end.
|
|
21
|
+
|
|
22
|
+
## Verified identical
|
|
23
|
+
|
|
24
|
+
| Item | Evidence (peer `lib/index.js`) | Ours |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `normalizeLineEndings` | `content.replaceAll("\r\n", "\n")` | identical |
|
|
27
|
+
| `detectLineEndings` | 4096-char sample; `crlf > lf - crlf` | identical |
|
|
28
|
+
| `restoreLineEndings` | LF unchanged; CRLF re-normalizes then `\n` → `\r\n` | identical (`replaceAll` vs `split/join` — equivalent) |
|
|
29
|
+
| occurrence counting | non-overlapping left-to-right | `split(needle).length - 1` — equivalent |
|
|
30
|
+
| `applyLiteralEdit` | empty needle, not-found, and ambiguous refusals; single-pass `split/join` without rescanning | identical |
|
|
31
|
+
| edit refusal strings | `old_string must be a non-empty string` (`FS_EDIT_NOT_FOUND`); ``old_string was not found in "${displayPath}"``; ``old_string matched ${n} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`` (`FS_AMBIGUOUS_EDIT`) | identical |
|
|
32
|
+
| invalid UTF-8 | ``cannot ${verb} "${displayPath}": invalid UTF-8 text`` (`FS_NOT_TEXT`) | identical |
|
|
33
|
+
| binary detection | 8192-byte sample, NUL check; ``cannot read "${displayPath}": binary file`` (`FS_NOT_TEXT`) | identical |
|
|
34
|
+
| size caps | ``cannot read "${displayPath}": ${size} bytes exceeds the ${maxBytes}-byte limit`` and ``content exceeds the ${maxBytes}-byte limit`` (`FS_TOO_LARGE`) | identical |
|
|
35
|
+
| `writeText` guards | not-regular (`FS_NOT_REGULAR_FILE`), `file no longer exists` / `file changed since it was read` (`FS_STALE_VERSION`), ``cannot overwrite existing "${displayPath}" without reading it first`` (`FS_NOT_OBSERVED`) | identical |
|
|
36
|
+
| `editText` guards | missing file refuses with `FS_STALE_VERSION` "file changed since it was read" (NOT `FS_NOT_FOUND`); not-regular; stale-version | identical — an earlier draft of this audit suspected a divergence here; the peer matches us |
|
|
37
|
+
| `listDir` refusal | ``cannot list "${displayPath}": not a directory`` (`FS_NOT_DIRECTORY`) | identical |
|
|
38
|
+
| diff-basis gate | `existing !== null && Buffer.byteLength(content) < diffBasisMaxBytes` — keyed on the NEW content's size, `null` above the cap | identical — the gate's new-size keying is inherited by design, not a divergence |
|
|
39
|
+
| outcome shapes | `operation: "update" \| "create"`, the `missing:${targetKey}` sentinel version, `before`/`after` | identical |
|
|
40
|
+
| version join | `` `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}` `` (local `index.js:146`, branded by `dsh-fs`'s `FsVersion`) | `versionOf` — identical shape |
|
|
41
|
+
|
|
42
|
+
## Deliberate divergences (declared, not drift)
|
|
43
|
+
|
|
44
|
+
1. **Distro-side version re-verification.** A `replace` write carrying an
|
|
45
|
+
expected version is re-checked by the agent against a fresh stat one
|
|
46
|
+
syscall before the rename (`FS_STALE_VERSION` on mismatch). The peer's
|
|
47
|
+
check-then-write window stops at the host; ours narrows to kernel-adjacent.
|
|
48
|
+
2. **Per-stream truncation flags.** The EXEC frame carries an output budget;
|
|
49
|
+
the RES line reports which streams the agent cut, surfaced as honest
|
|
50
|
+
`truncated` flags. The peer's collect-budget machinery is the spill-file
|
|
51
|
+
path we deliberately do not run.
|
|
52
|
+
3. **The transport around the ops** (ACK, KILL, the confined resident) is
|
|
53
|
+
protocol this package owns; the peer has no agent.
|
|
54
|
+
|
|
55
|
+
## Maintenance
|
|
56
|
+
|
|
57
|
+
When `package.json` bumps the peer pins, re-run this comparison — extract the
|
|
58
|
+
new tarballs and diff the regions above. The fastest canary: stat a file, list
|
|
59
|
+
its directory, and compare the versions (`test/fsio-agent.test.mjs` asserts the
|
|
60
|
+
intra-distro half of this); the strings above are the other half.
|
package/docs/README.zh.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/dsh-plugin-wsl-env)
|
|
7
7
|
[](../LICENSE)
|
|
8
8
|
|
|
9
|
-
这个插件让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把 WSL
|
|
9
|
+
这个插件让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把 WSL 子系统当作工作环境:命令在子系统里执行,模型的文件工具读写子系统里的真实文件,文件夹选择器可以直接打开子系统目录,GUI 终端也开在子系统里。
|
|
10
10
|
|
|
11
11
|
**只支持 Windows + WSL2。** 安装只需一条命令,插件自身没有依赖。环境按会话生效:打开 Windows 文件夹的会话继续使用原来的 Windows 环境。
|
|
12
12
|
|
|
@@ -26,9 +26,9 @@ dsh --profile wsl # 4. 启动
|
|
|
26
26
|
|
|
27
27
|
第 3 步应该打印出名为 `# == dsh-plugin-wsl-env` 的层,并且 `- id: terminal-controller` 上应出现 `shell: { path: wsl.exe, name: WSL }`。这个包是 DSH bundle,所以第 2 步会把 [`cordis.patch.yml`](../cordis.patch.yml) 作为配置层施加进去,没有任何需要手工合并的补丁。
|
|
28
28
|
|
|
29
|
-
然后在 GUI 里打开 `\\wsl.localhost
|
|
29
|
+
然后在 GUI 里打开 `\\wsl.localhost\<子系统>\...` 下的文件夹。选择器会在根一级列出每个已安装的子系统,**New terminal** 会在子系统里打开 shell。
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
还需要在子系统里安装 **bubblewrap**:运行 `npm run bootstrap -- <子系统> --install`(只读检测去掉 `--install`),或直接执行 `wsl.exe -d <子系统> -u root -- apt-get install -y bubblewrap`。没有它每条命令都会失败关闭,见[沙箱](#沙箱)。
|
|
32
32
|
|
|
33
33
|
卸载:`dsh plugin --profile wsl remove dsh-plugin-wsl-env`。升级:再执行一次同样的 `add` 命令。
|
|
34
34
|
|
|
@@ -36,12 +36,13 @@ dsh --profile wsl # 4. 启动
|
|
|
36
36
|
|
|
37
37
|
## 使用
|
|
38
38
|
|
|
39
|
-
把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"
|
|
39
|
+
把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"。它会在子系统里执行 `uname -r` 并读取该文件,不经过 `/mnt/c`,也不复制文件。
|
|
40
40
|
|
|
41
|
-
-
|
|
42
|
-
- **命令**以 `wsl.exe -d
|
|
43
|
-
-
|
|
44
|
-
- **终端**(右侧栏 → *New terminal
|
|
41
|
+
- **子系统文件夹自动获得子系统环境。** 创建会话时就会绑定 `wsl` preset,所以第一次工具调用就已经正确。
|
|
42
|
+
- **命令**以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <你的登录 shell> -lc <命令>` 执行,所以你的 `PATH`、`nvm`、`cargo`、`pyenv` 和 rc 配置都生效。shell 不是硬编码的 bash。
|
|
43
|
+
- **文件就是子系统的真实文件。** `/home/you/x` 与 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件,`/mnt/c/...` 通向 Windows 磁盘。
|
|
44
|
+
- **终端**(右侧栏 → *New terminal*)在子系统里打开会话目录下的 shell。
|
|
45
|
+
- **端口可见性**:子系统里监听中的端口经 `DSH_WSL_PORTS` 暴露给模型(约每 10 秒刷新);子系统内启动的 dev server 可由模型直接告知确切 URL(WSL2 的 localhost 转发是平台行为,Windows 侧直接可达)。
|
|
45
46
|
- **模型能看到自己的 shell 环境。** 插件向受管的 `DSH_*` 命名空间注册 `DSH_WSL_DISTRO`、`DSH_WSL_SHELL` 和 `DSH_WSL_HOME`。
|
|
46
47
|
- **权限与 Linux 主机一致。** 权限选择器在 `read-only`、`workspace-write`(默认)和 `danger-full-access` 之间切换。被拒绝的命令或写入会带回"放宽权限"的提议;批准后,这一次调用不加沙箱执行。
|
|
47
48
|
|
|
@@ -51,44 +52,53 @@ dsh --profile wsl # 4. 启动
|
|
|
51
52
|
|
|
52
53
|
| 行 | 键 | 默认值 | 说明 |
|
|
53
54
|
|---|---|---|---|
|
|
54
|
-
| `wsl-shell` | `distro` | `''` |
|
|
55
|
+
| `wsl-shell` | `distro` | `''` | 子系统名;空表示用 WSL 的默认子系统 |
|
|
55
56
|
| | `sandbox` | `true` | 用 `bubblewrap` 约束命令;`false` 表示不用沙箱 |
|
|
56
57
|
| `wsl-fs` | `distro` | `''` | 同上 |
|
|
57
|
-
| | `restrictToDistro` | `true` |
|
|
58
|
+
| | `restrictToDistro` | `true` | 拒绝指向**其它子系统**共享的路径;`/mnt/c` 属于本子系统内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
|
|
58
59
|
| | `sandbox` | `true` | 写入时按策略检查 `writeText` 和 `editText` |
|
|
60
|
+
| | `substrate` | `agent` | 文件工具使用哪种 I/O 基座:`agent` —— 常驻子系统内代理,读写与路径解析直接在 ext4 上完成(原生符号链接、原生权限位,写入 guard 存活到发布时刻,受限策略下由内核强制);`share` —— 退出项:Windows 侧宿主文件栈走 9p 共享。两种基座使用同一策略围栏;差异详见 [LIMITATIONS.md](LIMITATIONS.md) |
|
|
59
61
|
| `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
|
|
60
|
-
| `subprocess-wsl` | `distro` | `''` | GUI
|
|
62
|
+
| `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个子系统 |
|
|
61
63
|
|
|
62
64
|
[`cordis.patch.yml`](../cordis.patch.yml) 是每个随包值的注释参考。[docs/CONFIGURATION.md](CONFIGURATION.md) 列出其余键,包括 `shell`、`loginShell`、`cwd`、`timeoutMs`、`resolveSymlinks`、`preferredDistro` 和 `maxEntries`;[examples/profile.cordis.patch.yml](../examples/profile.cordis.patch.yml) 是一份可照抄的本机层。
|
|
63
65
|
|
|
64
66
|
## 沙箱
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
命令由**子系统内的 `bubblewrap`** 约束,文件写入按同一份策略检查。Windows ACL 沙箱在这里用不了:它的受限令牌完全到不了 WSL。
|
|
67
69
|
|
|
68
|
-
| 模式 |
|
|
70
|
+
| 模式 | 子系统里的命令可以做什么 |
|
|
69
71
|
|---|---|
|
|
70
|
-
| `read-only` |
|
|
72
|
+
| `read-only` | 整个子系统只读;`/dev` 是新建的可写挂载(`/dev/null` 与 `/dev/shm` 可用),其余不可写 |
|
|
71
73
|
| `workspace-write` | 在上一行基础上,把会话工作区绑定为可写,并把 `/tmp` 挂成临时目录 |
|
|
72
74
|
| `danger-full-access` | 不加沙箱;用于批准后的放宽权限请求 |
|
|
73
75
|
|
|
74
76
|
**bubblewrap 是必需项,而且失败是关闭的。** 没有它时,每条受限命令都报 `SANDBOX_UNAVAILABLE`,而不是不受约束地运行。要退出约束就在任一 provider 上设 `sandbox: false`,工具层会如实告诉模型这些操作没有沙箱。
|
|
75
77
|
|
|
76
|
-
**上报的强制程度是 `partial` 而不是 `full`。**
|
|
78
|
+
**上报的强制程度是 `partial` 而不是 `full`。** 子系统里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`npm run probe:sandbox` 会在你的机器上演示这条边界。
|
|
77
79
|
|
|
78
80
|
设计见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#sandbox),插件不做的事情见 [docs/LIMITATIONS.md](LIMITATIONS.md)。
|
|
79
81
|
|
|
82
|
+
## 配方
|
|
83
|
+
|
|
84
|
+
- **Git 凭据共享**:让子系统里的 git 使用 Windows 侧的 Git Credential Manager,避免每次输密码:
|
|
85
|
+
`git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
|
|
86
|
+
(路径按 Windows 侧 Git 的安装位置调整;WSL2 的 localhost 转发是平台行为,子系统内监听的端口 Windows 直接可达。)
|
|
87
|
+
- **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep / inotifywait(搜索与监视的后端)是否就位。
|
|
88
|
+
- **WSLENV 透传**:WSL 只导入 `WSLENV` 中列出的变量。本插件按前缀放行托管的 `DSH_*` 命名空间,其中带 Windows 路径的两个(`DSH_HOME`、`DSH_PROFILE_DIR`)加 `/p` 让 WSL 翻译成 `/mnt/c/...`。`PATH` 故意不透传——否则 Windows 的 PATH 会覆盖子系统自身的 PATH。
|
|
89
|
+
|
|
80
90
|
## 常见问题
|
|
81
91
|
|
|
82
92
|
| 现象 | 原因 | 处理 |
|
|
83
93
|
|---|---|---|
|
|
84
|
-
| 每条命令都报 `SANDBOX_UNAVAILABLE` |
|
|
94
|
+
| 每条命令都报 `SANDBOX_UNAVAILABLE` | 子系统里没有 `bubblewrap` | 执行 `npm run bootstrap -- <子系统> --install`,或在两个 provider 上都设 `sandbox: false` |
|
|
85
95
|
| 命令或写入在会话目录之外被拒绝 | `workspace-write` 的预期行为 | 接受工具给出的放宽权限提示,或把会话直接开在需要的目录上 |
|
|
86
96
|
| 连工作区内的写入也被拒绝 | 会处在 `read-only` 模式 | 切换权限选择器 |
|
|
87
97
|
| `dsh plugin add` 提示"没有激活任何层" | 依赖之前已经装过,`add` 没有需要记录的内容 | 先执行 `dsh plugin --profile wsl remove dsh-plugin-wsl-env`,再装一次 |
|
|
88
98
|
| 终端打开后仍然是 `cmd.exe` | 这一层里的 `terminal-controller` 行没有生效 | 用 `dsh --profile wsl --dump-config` 确认能看到 `shell: { path: wsl.exe, name: WSL }` |
|
|
89
99
|
| 改了 `lib/` 但不生效 | ES module 缓存 | 重启应用 |
|
|
90
|
-
| 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows
|
|
91
|
-
| `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash`
|
|
100
|
+
| 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在子系统内开发时用运行时镜像,见[开发](#开发) |
|
|
101
|
+
| `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash` 调用子系统内的工具 |
|
|
92
102
|
| 终端活动显示 `unknown` | 官方 shell 集成只对 POSIX 主机上直接启动的 `bash`/`zsh` 生效 | 关闭标签页以释放进程;空闲回收不会对它触发 |
|
|
93
103
|
| 结果里出现 `FS_*` 码 | 码本身说明了是谁拒绝的、以及怎样解除 | 见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#error-codes) 的错误码表 |
|
|
94
104
|
|
|
@@ -97,10 +107,11 @@ dsh --profile wsl # 4. 启动
|
|
|
97
107
|
```bash
|
|
98
108
|
npm test # 风格与打包检查、语法检查、单元测试
|
|
99
109
|
npm run test:coverage # 带覆盖率阈值的单元测试(需 Node 22.8+)
|
|
100
|
-
npm run probe:sandbox #
|
|
101
|
-
npm run probe #
|
|
110
|
+
npm run probe:sandbox # 在子系统里实测 bubblewrap 能约束什么、不能约束什么
|
|
111
|
+
npm run probe # 文件系统探针,需要真实子系统(仅 Windows + WSL)
|
|
102
112
|
npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
|
|
103
113
|
npm run probe:terminal # 通过终端 provider 打开一个 PTY
|
|
114
|
+
npm run probe:substrate # 用真实 wsl.exe 传输驱动 agent 文件基座(在子系统内运行)
|
|
104
115
|
npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
|
|
105
116
|
npm run probe:picker # 列出选择器的根级、拒绝路径与上限(仅 Windows + WSL)
|
|
106
117
|
npm run probe:mode # 哪些 POSIX 权限事实能穿过共享层(仅需 Windows node)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Upstream proposal: export the fsio module from `dsh-fs-local`
|
|
2
|
+
|
|
3
|
+
Status: draft, targeting `deepseek-harness` @ `packages/fs/fs-local` (version
|
|
4
|
+
line `0.2.0-rc.x`). This is the Shape-A upstream half of the in-distro I/O
|
|
5
|
+
refactor: with the fsio module exported, `dsh-plugin-wsl-env` deletes its
|
|
6
|
+
in-plugin replicas (`lib/fsio-text.js`) and imports the originals.
|
|
7
|
+
|
|
8
|
+
## The change
|
|
9
|
+
|
|
10
|
+
The fsio module already exists, is fully documented, and is pure (no peers
|
|
11
|
+
beyond types). It is only not reachable: `package.json` exports `"."` and
|
|
12
|
+
`"./package.json"`, and `lib/index.js` does not re-export the helpers. Two
|
|
13
|
+
lines of surface, no behaviour change:
|
|
14
|
+
|
|
15
|
+
```diff
|
|
16
|
+
--- a/packages/fs/fs-local/package.json
|
|
17
|
+
+++ b/packages/fs/fs-local/package.json
|
|
18
|
+
@@
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./lib/types/index.d.ts",
|
|
22
|
+
"default": "./lib/index.js"
|
|
23
|
+
},
|
|
24
|
+
+ "./fsio": {
|
|
25
|
+
+ "types": "./lib/types/fsio.d.ts",
|
|
26
|
+
+ "default": "./lib/fsio.js"
|
|
27
|
+
+ },
|
|
28
|
+
"./src/*": "./src/*",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```diff
|
|
34
|
+
--- a/packages/fs/fs-local/lib/index.js
|
|
35
|
+
+++ b/packages/fs/fs-local/lib/index.js
|
|
36
|
+
@@
|
|
37
|
+
-export { LocalFileSystem, LocalFileSystem as default };
|
|
38
|
+
+export {
|
|
39
|
+
+ LocalFileSystem,
|
|
40
|
+
+ LocalFileSystem as default,
|
|
41
|
+
+ applyLiteralEdit,
|
|
42
|
+
+ detectLineEndings,
|
|
43
|
+
+ normalizeLineEndings,
|
|
44
|
+
+ restoreLineEndings,
|
|
45
|
+
+};
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
(The module layout differs between the repo's `src/` and the built `lib/`;
|
|
49
|
+
the export list is what matters. `readForEdit` and `readTextForDiff` stay
|
|
50
|
+
internal — they are I/O orchestration, which the plugin drives against its own
|
|
51
|
+
substrate — only the pure text mechanics are needed.)
|
|
52
|
+
|
|
53
|
+
## Why the plugin needs it
|
|
54
|
+
|
|
55
|
+
`dsh-plugin-wsl-env`'s agent substrate (`lib/fsio-agent.js`,
|
|
56
|
+
`lib/fs-substrate.js`) re-runs fsio's orchestration inside a WSL distro: reads,
|
|
57
|
+
writes, identities and edits execute where the files live, and the host side
|
|
58
|
+
keeps only the validation and edit mechanics. Those mechanics —
|
|
59
|
+
`applyLiteralEdit`, the line-ending trio, the binary NUL sample, the fatal UTF-8
|
|
60
|
+
decode — are replicated today (`lib/fsio-text.js`) because the package does not
|
|
61
|
+
export them. The replica is byte-for-byte faithful, including error messages,
|
|
62
|
+
because the tool layer matches on both the code and the text the model reads;
|
|
63
|
+
but a replica is a fork surface: every upstream change to the edit algorithm
|
|
64
|
+
has to be re-ported by hand.
|
|
65
|
+
|
|
66
|
+
With the export in place the plugin deletes `lib/fsio-text.js` and imports:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { applyLiteralEdit, detectLineEndings, normalizeLineEndings, restoreLineEndings } from "@deepseek-ai/dsh-fs-local/fsio";
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Why this is safe upstream
|
|
73
|
+
|
|
74
|
+
- No new API surface beyond re-exporting functions that already exist and are
|
|
75
|
+
documented in `lib/types/fsio.d.ts`.
|
|
76
|
+
- No behaviour change: the class's own call sites are untouched.
|
|
77
|
+
- The functions are pure (text in, text out, structured errors), so exporting
|
|
78
|
+
them cannot widen the I/O contract.
|
|
79
|
+
- Semver: additive export, a minor bump.
|
|
80
|
+
|
|
81
|
+
## Follow-up (separate proposal, not required)
|
|
82
|
+
|
|
83
|
+
A larger seam — parameterizing `LocalFileSystem`'s fsio call sites so a
|
|
84
|
+
subclass can substitute an I/O substrate — would let integrations inherit the
|
|
85
|
+
whole backend instead of re-running its orchestration. The export above is the
|
|
86
|
+
prerequisite and is sufficient on its own; the parameterization is worth
|
|
87
|
+
discussing only after a consumer exists in the wild.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confined residents: one long-lived agent per (distro, policy) pair, running
|
|
3
|
+
* its WHOLE lifetime inside the bwrap profile that policy asks for — so every
|
|
4
|
+
* filesystem op it performs is enforced by the kernel's mount table, not just
|
|
5
|
+
* by the host-side check that chose it.
|
|
6
|
+
*
|
|
7
|
+
* This is stage two of the substrate's enforcement story. Stage one routed
|
|
8
|
+
* every op through the plain agent with the policy check deciding what may
|
|
9
|
+
* pass; stage two routes *mutations* to a confined resident whose view of the
|
|
10
|
+
* filesystem already matches the granted rights, so a bug in the check cannot
|
|
11
|
+
* become a write outside the workspace — the kernel refuses it.
|
|
12
|
+
*
|
|
13
|
+
* Keying is the whole design: the profile is fixed for an agent's lifetime,
|
|
14
|
+
* so a change in the policy (the Permissions selector, a session move) simply
|
|
15
|
+
* addresses a different key, and the previous resident retires through its own
|
|
16
|
+
* idle timer. Escalated writes (`danger-full-access`) keep addressing the
|
|
17
|
+
* plain agent, which is what an approved escalation means.
|
|
18
|
+
*
|
|
19
|
+
* The argv it hands the transport is a whole command — program, profile, `--` —
|
|
20
|
+
* assembled by the peer-free `lib/bwrap.js`, which both confinement sites share.
|
|
21
|
+
* That import is why this module needs no DSH peer either: a bare checkout can
|
|
22
|
+
* construct the factory and assert the command it produces, which is what
|
|
23
|
+
* `test/agent.test.mjs` does. It used to import the sandbox's own profile
|
|
24
|
+
* builder and assemble the prefix itself, missing the program; the probe inlined
|
|
25
|
+
* a third copy, so nothing compared production's command with a working one and
|
|
26
|
+
* every confined mutation failed the resident's handshake.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-plugin-wsl/agent-confined
|
|
29
|
+
*/
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { bwrapArgvPrefix } from "./bwrap.js";
|
|
32
|
+
import { toLinuxPath } from "./paths.js";
|
|
33
|
+
import { WslAgent } from "./agent.js";
|
|
34
|
+
|
|
35
|
+
const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
|
|
36
|
+
const confinedAgents = new Map();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The confined agent for one distro and one resolved policy, created on first
|
|
40
|
+
* use and shared for the process lifetime. A success is remembered; a failed
|
|
41
|
+
* start is not (the agent's own rebuild rules take over from there).
|
|
42
|
+
*
|
|
43
|
+
* @param {object} request - the confinement request.
|
|
44
|
+
* @param {string} request.distro - the distro to run in.
|
|
45
|
+
* @param {string} [request.wslPath] - the `wsl.exe` path.
|
|
46
|
+
* @param {{mode: string, workspaceRoot: string}} request.policy - the RESOLVED
|
|
47
|
+
* file-effect policy: the profile binds exactly what the mode grants.
|
|
48
|
+
* @param {boolean} [request.maskWindowsDrive] - shadow `/mnt` with an empty
|
|
49
|
+
* tmpfs in the resident's profile; part of the cache key, since it changes
|
|
50
|
+
* the mount table the resident lives under.
|
|
51
|
+
* @returns {WslAgent} the confined resident for this key.
|
|
52
|
+
*/
|
|
53
|
+
export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindowsDrive = false }) {
|
|
54
|
+
const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}`;
|
|
55
|
+
let agent = confinedAgents.get(key);
|
|
56
|
+
if (agent === undefined) {
|
|
57
|
+
agent = new WslAgent({
|
|
58
|
+
distro,
|
|
59
|
+
wslPath,
|
|
60
|
+
scriptPath: toLinuxPath(AGENT_SCRIPT_PATH),
|
|
61
|
+
// The prefix is a whole command from the distro's point of view — program,
|
|
62
|
+
// profile, `--` — because the transport inserts it between
|
|
63
|
+
// `wsl.exe --exec` and the `sh <script>` pair (`lib/agent.js`'s
|
|
64
|
+
// `spawnDefault`). Both confinement sites share that assembly in
|
|
65
|
+
// `lib/bwrap.js`, whose module doc records what happened when they did not.
|
|
66
|
+
argvPrefix: bwrapArgvPrefix(
|
|
67
|
+
{ mode: policy.mode, workspaceRoot: toLinuxPath(policy.workspaceRoot, { distro }) },
|
|
68
|
+
{ maskWindowsDrive },
|
|
69
|
+
),
|
|
70
|
+
});
|
|
71
|
+
confinedAgents.set(key, agent);
|
|
72
|
+
}
|
|
73
|
+
return agent;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Test hook: forget every confined resident (the plain agent has no factory state). */
|
|
77
|
+
export function resetConfinedAgents() {
|
|
78
|
+
confinedAgents.clear();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default confinedAgent;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error a {@link WslAgent} rejects with when the resident process is out
|
|
3
|
+
* for this host process — the executor's cue to fall back to the one-shot
|
|
4
|
+
* `wsl.exe` path. Deliberately its own class: callers must not confuse
|
|
5
|
+
* "the agent cannot serve" with a failure of the command itself.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-plugin-wsl/agent-errors
|
|
8
|
+
*/
|
|
9
|
+
export class AgentUnavailableError extends Error {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} message - why the agent is out, for the log.
|
|
12
|
+
*/
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "AgentUnavailableError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The request's workdir could not be entered inside the distro. Carries the
|
|
21
|
+
* Linux path so a caller can synthesize the same diagnostic the one-shot
|
|
22
|
+
* `wsl.exe` path produces from the relay's `chdir(...) failed` text.
|
|
23
|
+
*/
|
|
24
|
+
export class CwdError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} linuxPath - the path that could not be entered.
|
|
27
|
+
*/
|
|
28
|
+
constructor(linuxPath) {
|
|
29
|
+
super(`could not enter working directory "${linuxPath}"`);
|
|
30
|
+
this.name = "CwdError";
|
|
31
|
+
this.linuxPath = linuxPath;
|
|
32
|
+
}
|
|
33
|
+
}
|