dsh-plugin-wsl-env 0.5.0 → 0.6.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 +80 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +3 -0
- package/agent/wsl-agent.sh +41 -8
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/CONFIGURATION.md +1 -0
- package/docs/PARITY.md +4 -0
- package/docs/README.zh.md +3 -0
- package/lib/index.js +26 -10
- package/lib/sandbox-core.js +157 -0
- package/lib/sandbox.js +11 -131
- package/lib/watcher.js +63 -12
- package/lib/wsl.js +24 -2
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,86 @@ reference below points at that record's numbering.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.6.0] - 2026-10-04
|
|
16
|
+
|
|
17
|
+
One theme: the P2 backlog from the 2026-10-03 review, closed with the same
|
|
18
|
+
evidence bar as its P0/P1 — every fix carries a check that fails without it,
|
|
19
|
+
and the real-distro probes run green over the changed paths — plus the
|
|
20
|
+
import-time regression that backlog carried, caught at the first
|
|
21
|
+
sync-and-restart: `watchMaxDepth` was written `z.number().int()`, a member the
|
|
22
|
+
pinned schemastery does not publish, which took `wsl-shell` and `wsl-fs` down
|
|
23
|
+
in every distro workspace session. The key now uses `z.natural()`, and
|
|
24
|
+
`test/syntax.mjs` reads the chained half of the schemastery surface so the
|
|
25
|
+
next such member dies in CI instead of in the app.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **The in-distro watcher no longer fails silently (review M10).** Four defects
|
|
30
|
+
in `lib/watcher.js`, fixed together because they share one loop:
|
|
31
|
+
the stamp file is removed on every way out (EXIT trap; TERM/INT/HUP routed
|
|
32
|
+
through a plain exit so the trap runs); a target that vanishes mid-watch now
|
|
33
|
+
ends the loop with a report through the error channel instead of arming
|
|
34
|
+
forever (`E` protocol line, unit- and probe-tested); the scan's depth is
|
|
35
|
+
boundable through the new `wsl-fs.watchMaxDepth` key (`0`, the default,
|
|
36
|
+
keeps the whole-tree walk); and the loop's stderr is drained continuously
|
|
37
|
+
with its tail kept for error context, so a chatty `sh` can no longer stall
|
|
38
|
+
the watch at the 64 KiB pipe limit.
|
|
39
|
+
- **`watchMaxDepth` no longer takes both preset rows down at import (a
|
|
40
|
+
regression the M10 entry above introduced, caught after the first
|
|
41
|
+
sync-and-restart).** The key was written `z.number().int().min(0)`, but the
|
|
42
|
+
pinned schemastery publishes no `int` on any schema instance, and a Config
|
|
43
|
+
schema is a class field — so `lib/index.js` threw
|
|
44
|
+
`TypeError: z.number(...).int is not a function` while it was still being
|
|
45
|
+
imported, the loader left `wsl-shell` and `wsl-fs` fiber-less, and the preset
|
|
46
|
+
audit reported exactly those two rows as `never started`: every session in a
|
|
47
|
+
distro workspace, new or historical, failed at the first message. The key is
|
|
48
|
+
now `z.natural().default(0)` — the pinned spelling of "integer >= 0", the
|
|
49
|
+
idiom `picker` and `auto-preset` already use — and `test/syntax.mjs` now
|
|
50
|
+
reads the chained half of the schemastery surface (a balanced walk that
|
|
51
|
+
survives nested call arguments, string literals and template
|
|
52
|
+
interpolations), so a member the peer does not publish fails the suite
|
|
53
|
+
instead of waiting for the Desktop app. Reproduced and verified in a
|
|
54
|
+
peer-stub harness: the `lib/index.js` import went from the `TypeError` to
|
|
55
|
+
clean with the fix, and the new check fails on the old line.
|
|
56
|
+
- **A wedged WSL service can no longer hang a tool call forever (review M11).**
|
|
57
|
+
Every `wsl.exe` resolution call in `lib/wsl.js` now runs under a deadline
|
|
58
|
+
(`DEFAULT_WSL_DEADLINE_MS`, 60 s — generous on purpose for cold boots, `0`
|
|
59
|
+
waits forever), and the deadline kill is named for what it is in the error,
|
|
60
|
+
with the `wsl.exe --shutdown` remedy. Separately, the agent execution path's
|
|
61
|
+
`confine()` now receives the request's abort signal, the same one the
|
|
62
|
+
one-shot path always passed.
|
|
63
|
+
- **A timed-out command can no longer leave descendants running (review M12).**
|
|
64
|
+
EXEC requests run as their own session and process group (`setsid`, detected
|
|
65
|
+
at startup with a documented fallback), and the in-distro timeout and KILL
|
|
66
|
+
frames signal the whole group — TERM, then KILL after the grace, descendants
|
|
67
|
+
included. Proven by a new real-script check: a command that backgrounds a
|
|
68
|
+
marked shell and outlives its budget leaves zero survivors.
|
|
69
|
+
- **Documentation drift cluster (review M9).** ARCHITECTURE.md's service table
|
|
70
|
+
claimed the share substrate was the default (it is `agent`), pointed at
|
|
71
|
+
`lib/sandbox.js` for `bwrapProfileArgs` (built in `lib/bwrap.js`), and kept
|
|
72
|
+
the long-gone `watch()` refusal in the error-code table; the `substrate`
|
|
73
|
+
schema docstring claimed watches run through the substrate (both substrates
|
|
74
|
+
ride the same `lib/watcher.js` poll loop); PARITY.md now records the rg
|
|
75
|
+
interception as designed-but-never-implemented instead of leaving it
|
|
76
|
+
implied; and the probe checklists (both READMEs, CONTRIBUTING, RELEASING,
|
|
77
|
+
the PR template, CI's comment) list the agent, watch, substrate and exec
|
|
78
|
+
probes that had shipped without being listed anywhere.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- **Test structure (review M15).** The sandbox checks run everywhere now:
|
|
83
|
+
the probe-and-confine logic moved to the peer-free `lib/sandbox-core.js`
|
|
84
|
+
(one implementation, still shipped), `lib/sandbox.js` binds it to the peer's
|
|
85
|
+
`SandboxUnavailableError`, and the tests inject a stand-in with the same
|
|
86
|
+
`code` — CI no longer skips them. `FS_SANDBOX_DENIED`'s write-only
|
|
87
|
+
classification carries three unit checks (write denial classified, the same
|
|
88
|
+
signature ignored off the write path, ordinary permission failures keep
|
|
89
|
+
theirs). The probes stopped carrying inline bwrap profiles: `exec.sh` and
|
|
90
|
+
`sandbox.sh` derive their profiles from `lib/bwrap.js` itself, so a profile
|
|
91
|
+
change fails the probe instead of hiding behind a copy. `lib/wsl.js`'s
|
|
92
|
+
spawning half — previously probe-only — gained `test/wsl.test.mjs`.
|
|
93
|
+
`test/probe/agent.sh` is wired as `npm run probe:agent` like its siblings.
|
|
94
|
+
|
|
15
95
|
## [0.5.0] - 2026-10-04
|
|
16
96
|
|
|
17
97
|
One theme: telling the truth, in the reader's language. The Plugins page now
|
package/CONTRIBUTING.md
CHANGED
|
@@ -69,6 +69,10 @@ terminal if you would rather not see the prompt.
|
|
|
69
69
|
| `npm run probe:sandbox` | what bubblewrap confines and what it does not | no |
|
|
70
70
|
| `npm run probe:sandbox-shell` | the confined executor through a real boot | yes |
|
|
71
71
|
| `npm run probe:terminal` | the terminal provider | yes |
|
|
72
|
+
| `npm run probe:substrate` | the agent filesystem substrate over a real `wsl.exe` transport | yes; inside the distro |
|
|
73
|
+
| `npm run probe:watch` | the in-distro watcher over a real directory | yes; inside the distro |
|
|
74
|
+
| `npm run probe:agent` | the resident-vs-one-shot fallback parity legs | yes; inside the distro |
|
|
75
|
+
| `npm run probe:exec` | the agent-backed execution handle: timeout, kill, cwd failure | yes; inside the distro |
|
|
72
76
|
| `npm run probe:missing-wsl` | how a `wslPath` that cannot start is reported | yes |
|
|
73
77
|
| `npm run probe:picker` | the picker's root level, its refusals and its cap | yes |
|
|
74
78
|
| `npm run probe:mode` | which POSIX-mode facts survive the share | no; Windows Node |
|
package/README.md
CHANGED
|
@@ -143,6 +143,9 @@ npm run probe # filesystem probe against a real distro (Windows +
|
|
|
143
143
|
npm run probe:sandbox-shell # boot a real harness and drive the confined executor
|
|
144
144
|
npm run probe:terminal # open a PTY through the terminal provider
|
|
145
145
|
npm run probe:substrate # drive the agent filesystem substrate over a real wsl.exe transport (from inside the distro)
|
|
146
|
+
npm run probe:watch # arm the in-distro watcher over a real directory (from inside the distro)
|
|
147
|
+
npm run probe:agent # the resident-vs-one-shot fallback parity legs (from inside the distro)
|
|
148
|
+
npm run probe:exec # the agent-backed execution handle: timeout, kill, cwd failure (from inside the distro)
|
|
146
149
|
npm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
|
|
147
150
|
npm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
|
|
148
151
|
npm run probe:mode # which POSIX-mode facts survive the share (Windows node only)
|
package/agent/wsl-agent.sh
CHANGED
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
# With a cap, stdout and stderr are each cut at that many bytes and
|
|
28
28
|
# the flags say which streams were cut; 0 means the whole capture.
|
|
29
29
|
# or ERR|<id>|<reason>|<b64 message> (reason: cwd)
|
|
30
|
+
# An EXEC request runs as its own session and process group (`setsid`),
|
|
31
|
+
# so a timeout or a KILL frame takes the whole descendant tree — not
|
|
32
|
+
# just the direct child, which is how a build tool's daemonized
|
|
33
|
+
# grandchild outlives the kill.
|
|
30
34
|
# FS|<id>|<op>|<timeout seconds|0>|<n args>
|
|
31
35
|
# followed by <n args> lines of base64, one argument each — arguments
|
|
32
36
|
# arrive still base64 so a write's content never passes through a shell
|
|
@@ -81,31 +85,58 @@ b64enc_file() {
|
|
|
81
85
|
|
|
82
86
|
kill_current() {
|
|
83
87
|
if [ -n "$CURRENT_PID" ]; then
|
|
84
|
-
|
|
88
|
+
kill_request TERM "$CURRENT_PID"
|
|
85
89
|
fi
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
# Signal one in-flight request, descendants included. With `setsid` (EXEC
|
|
93
|
+
# requests, below) the request is its own process-group leader, so the
|
|
94
|
+
# negative PID signals the WHOLE group — every descendant that did not make
|
|
95
|
+
# its own session. Without it the group does not exist, the negative-PID kill
|
|
96
|
+
# fails with ESRCH, and the direct kill keeps the old guarantee. A shell whose
|
|
97
|
+
# `kill` builtin cannot parse `-- -PID` at all also lands here.
|
|
98
|
+
kill_request() {
|
|
99
|
+
# $1 = signal name, $2 = request PID.
|
|
100
|
+
kill -s "$1" -- "-$2" 2>/dev/null || kill -s "$1" "$2" 2>/dev/null || :
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
shutdown() {
|
|
89
104
|
kill_current
|
|
90
105
|
exit 0
|
|
91
106
|
}
|
|
92
107
|
trap shutdown TERM INT HUP
|
|
93
108
|
|
|
109
|
+
# Whether EXEC requests can run as their own session. `setsid` makes the
|
|
110
|
+
# request process a session AND process-group leader (pid = pgid), which is
|
|
111
|
+
# what lets kill_request signal the whole descendant tree; the agent's own
|
|
112
|
+
# shell cannot do this portably — `set -m` in a non-interactive dash has been
|
|
113
|
+
# observed skipping the setpgid entirely, and a kill against a PID that is
|
|
114
|
+
# not a group leader is a no-op. A distro without `setsid` keeps working:
|
|
115
|
+
# requests then run in the agent's own group and the direct-child kill of the
|
|
116
|
+
# pre-setsid behaviour applies.
|
|
117
|
+
SETSID=
|
|
118
|
+
if command -v setsid >/dev/null 2>&1; then
|
|
119
|
+
SETSID=setsid
|
|
120
|
+
fi
|
|
121
|
+
|
|
94
122
|
timeout_watcher() {
|
|
95
|
-
# $1 = seconds, $2 = pid, $3 = grace seconds. TERM first
|
|
96
|
-
#
|
|
97
|
-
# the
|
|
123
|
+
# $1 = seconds, $2 = pid, $3 = grace seconds. TERM first — the whole group,
|
|
124
|
+
# so a command that ignores TERM takes its descendants with it when the
|
|
125
|
+
# KILL lands; a process that ignores TERM gets KILLed after the grace, so a
|
|
126
|
+
# stuck command cannot hang the request forever.
|
|
98
127
|
sleep "$1"
|
|
99
|
-
|
|
128
|
+
kill_request TERM "$2"
|
|
100
129
|
sleep "${3:-3}"
|
|
101
|
-
|
|
130
|
+
kill_request KILL "$2"
|
|
102
131
|
}
|
|
103
132
|
|
|
104
133
|
# Capture one request's output and answer with RES. $1 = request id, $2 =
|
|
105
134
|
# timeout seconds ('' or 0 = none), $3 = per-stream output cap in bytes (0 or
|
|
106
135
|
# empty = unlimited), $4 = launch mode: `exec` runs "$@" (argv words) as the
|
|
107
|
-
# request process
|
|
108
|
-
# the
|
|
136
|
+
# request process, in its own session via `setsid` when the distro has it —
|
|
137
|
+
# the timeout and KILL then signal the whole descendant group; `subshell` runs
|
|
138
|
+
# ( "$@" ) in a forked subshell, which is how the FS handlers — shell
|
|
139
|
+
# functions, not executables — run.
|
|
109
140
|
run_capture() {
|
|
110
141
|
local req_id timeout_s maxout mode rc watcher_pid out_cut err_cut
|
|
111
142
|
req_id=$1
|
|
@@ -121,6 +152,8 @@ run_capture() {
|
|
|
121
152
|
CURRENT_ID=$req_id
|
|
122
153
|
if [ "$mode" = subshell ]; then
|
|
123
154
|
( "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
|
|
155
|
+
elif [ -n "$SETSID" ]; then
|
|
156
|
+
( exec setsid "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
|
|
124
157
|
else
|
|
125
158
|
"$@" >"$OUT_FILE" 2>"$ERR_FILE" &
|
|
126
159
|
fi
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -19,7 +19,7 @@ smaller integrations.
|
|
|
19
19
|
| Service | Class | File | What it does |
|
|
20
20
|
|---|---|---|---|
|
|
21
21
|
| `ctx.shell` | `WslShellExecutor` | [`lib/index.js`](../lib/index.js) | Runs each command as `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>`, wrapped in a distro-side `bwrap` sandbox. |
|
|
22
|
-
| `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Serves the file tools from real distro files, on one of two I/O substrates (see below): the Windows-side host stack over the distro's UNC share
|
|
22
|
+
| `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Serves the file tools from real distro files, on one of two I/O substrates (see below): the resident in-distro agent on ext4 (default), or the Windows-side host stack over the distro's UNC share. |
|
|
23
23
|
| `ctx.subprocess` | `WslSubprocessRuntime` | [`lib/subprocess.js`](../lib/subprocess.js) | Opens the GUI terminal inside the distro, in the session workspace. |
|
|
24
24
|
|
|
25
25
|
`WslShellExecutor` extends the shipped `LocalBashExecutor`, and `WslFileSystem`
|
|
@@ -146,7 +146,8 @@ wsl.exe -d <distro> --cd <linux dir> --exec bwrap \
|
|
|
146
146
|
|
|
147
147
|
These are the same arguments DSH's Linux runner uses (`dsh-sandbox-local`), so the
|
|
148
148
|
behaviour and the error messages match a Linux host. The function
|
|
149
|
-
`bwrapProfileArgs` in [`lib/
|
|
149
|
+
`bwrapProfileArgs` in [`lib/bwrap.js`](../lib/bwrap.js) builds them — the
|
|
150
|
+
peer-free builder every confinement site composes from.
|
|
150
151
|
|
|
151
152
|
| Mode | What a command inside the distro can do |
|
|
152
153
|
|---|---|
|
|
@@ -208,7 +209,7 @@ finds it here.
|
|
|
208
209
|
| `FS_OUTSIDE_DISTRO` | `WslFileSystem.worldPath` | `restrictToDistro` is on and the path names **another distro**'s share — a configuration fence, not a sandbox decision | open a session in that distro, or set `restrictToDistro: false`. A wider permission does **not** lift it |
|
|
209
210
|
| `FS_NOT_OBSERVED` | `WslFileSystem.assertGuard` | a guarded create (`createIfAbsent`) targeted a file that exists and had not been read first | read the file, then overwrite with the version guard or edit it |
|
|
210
211
|
| `FS_STALE_VERSION` | `WslFileSystem.assertGuard` | the version the caller holds no longer matches: the file changed, or is gone | read it again and retry with the fresh version |
|
|
211
|
-
| `FS_IO_ERROR` | `WslFileSystem.watch` |
|
|
212
|
+
| `FS_IO_ERROR` | `WslFileSystem.watch` | the watch target is not a path inside the pinned distro, so there is nothing the in-distro poll loop can observe | watch a distro path; the watch itself runs where the files live (`lib/watcher.js`) |
|
|
212
213
|
| `FS_NOT_FOUND` | the host filesystem stack | the path does not exist — the ordinary answer, passed through | — |
|
|
213
214
|
|
|
214
215
|
Two failures come from `wsl.exe` itself rather than from a code of ours, and both used
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -26,6 +26,7 @@ are schema defaults, listed because they are the ones worth knowing.
|
|
|
26
26
|
| | `substrate` | `agent` *(shipped)* | which I/O substrate serves the file tools: `agent` — the resident in-distro agent (`lib/fs-substrate.js`), where reads, writes and identities run on ext4: native symlinks, native mode bits, and the mutation guard survives to the write's atomic publication, kernel-enforced by the confined resident when the policy is confined. `share` — the opt-out: the Windows-side host stack over the 9p share, with the symlink-retry and publication hooks. Both substrates fence writes with the same host-side policy check. See [LIMITATIONS.md](LIMITATIONS.md) for what changes between them |
|
|
27
27
|
| | `resolveSymlinks` | `true` | *(share substrate only)* follow Linux symlinks that the share cannot traverse, such as `/etc/os-release` and `/bin` — the agent substrate follows them natively |
|
|
28
28
|
| | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
|
|
29
|
+
| | `watchMaxDepth` | `0` | how deep the in-distro watch scan walks; `0` walks the whole tree. Bound it (e.g. `4`) when the watched tree holds a `node_modules` — a change below the bound is seen only when another change above it fires the same callback |
|
|
29
30
|
| `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
|
|
30
31
|
| | `includeHostHome` | `true` *(shipped)* | also list the Windows home directory |
|
|
31
32
|
| | `maxEntries` | `1000` *(shipped)* | maximum entries listed per directory |
|
package/docs/PARITY.md
CHANGED
|
@@ -40,6 +40,10 @@ shaped a phase of the plan.
|
|
|
40
40
|
distro's share, rewrite the spawn to run a distro-side `rg` (falling back
|
|
41
41
|
to `grep -rE` + `find` when absent) with identical stdout conventions.
|
|
42
42
|
Under the agent, that exec rides the agent's `exec` capability.
|
|
43
|
+
**Not implemented, and recorded so the gap is a decision, not an
|
|
44
|
+
omission:** search still runs the packaged Windows rg over the share. The
|
|
45
|
+
interception was designed (above) but never landed; nothing in the phase
|
|
46
|
+
table below delivers it, and no phase claims it.
|
|
43
47
|
3. **`dsh-fs-local` watches with chokidar over the share** — the behaviour
|
|
44
48
|
this plugin replaces: chokidar on 9p is the unreliability that made
|
|
45
49
|
`WslFileSystem.watch` refuse. The in-distro watcher replaces it wholesale.
|
package/docs/README.zh.md
CHANGED
|
@@ -134,6 +134,9 @@ npm run probe # 文件系统探针,需要真实子系统(仅
|
|
|
134
134
|
npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
|
|
135
135
|
npm run probe:terminal # 通过终端 provider 打开一个 PTY
|
|
136
136
|
npm run probe:substrate # 用真实 wsl.exe 传输驱动 agent 文件基座(在子系统内运行)
|
|
137
|
+
npm run probe:watch # 在真实目录上装上 distro 内轮询 watcher(在子系统内运行)
|
|
138
|
+
npm run probe:agent # resident 与 one-shot 的回退不变量对比腿(在子系统内运行)
|
|
139
|
+
npm run probe:exec # agent 执行句柄:超时、杀停、cwd 失败(在子系统内运行)
|
|
137
140
|
npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
|
|
138
141
|
npm run probe:picker # 列出选择器的根级、拒绝路径与上限(仅 Windows + WSL)
|
|
139
142
|
npm run probe:mode # 哪些 POSIX 权限事实能穿过共享层(仅需 Windows node)
|
package/lib/index.js
CHANGED
|
@@ -580,7 +580,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
580
580
|
async executeViaAgent(resolved, distro, shell) {
|
|
581
581
|
const policy = resolved.sandboxPolicy;
|
|
582
582
|
const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
|
|
583
|
-
? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro })
|
|
583
|
+
? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro, signal: resolved.signal })
|
|
584
584
|
: undefined;
|
|
585
585
|
const mode = policy?.mode;
|
|
586
586
|
const execution = agentExecutionHandle({
|
|
@@ -788,6 +788,15 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
788
788
|
* distinct failing path, cached.
|
|
789
789
|
*/
|
|
790
790
|
resolveSymlinks: z.boolean().default(true),
|
|
791
|
+
/**
|
|
792
|
+
* How deep the in-distro watch scan walks, `1` = the watched directory and
|
|
793
|
+
* its direct children. `0` — the default — walks the whole tree, which is
|
|
794
|
+
* what the invalidation contract asks for but a real cost every poll over
|
|
795
|
+
* a tree containing a `node_modules`; a bound trades that cost for a
|
|
796
|
+
* documented blind spot: a change below the bound is not seen until
|
|
797
|
+
* another change above it fires the same callback.
|
|
798
|
+
*/
|
|
799
|
+
watchMaxDepth: z.natural().default(0),
|
|
791
800
|
/**
|
|
792
801
|
* Run the in-distro side of publication (mode copy + atomic rename) through
|
|
793
802
|
* the resident agent (`agent/wsl-agent.sh`), one round trip instead of a
|
|
@@ -799,15 +808,17 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
799
808
|
agent: z.boolean().default(true),
|
|
800
809
|
/**
|
|
801
810
|
* Which I/O substrate serves the file tools. `"agent"` (the default) —
|
|
802
|
-
* the resident in-distro agent (`lib/fs-substrate.js`): reads, writes
|
|
803
|
-
* identities
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
* `
|
|
811
|
+
* the resident in-distro agent (`lib/fs-substrate.js`): reads, writes and
|
|
812
|
+
* identities run where the files live, symlinks and mode bits are native,
|
|
813
|
+
* and the mutation guard survives to the write's no-replace publication,
|
|
814
|
+
* kernel-enforced by the confined resident when the policy is confined.
|
|
815
|
+
* `"share"` — the opt-out: the Windows-side host filesystem stack over
|
|
816
|
+
* the distro's 9p share, with the symlink-retry and publication hooks
|
|
817
|
+
* that compensate for what the share cannot do. Both substrates fence
|
|
818
|
+
* writes with the same host-side policy check, and `watch()` rides the
|
|
819
|
+
* same in-distro poll loop for both (`lib/watcher.js`) — it never runs
|
|
820
|
+
* through either substrate. Neither substrate changes `sandboxMode`'s
|
|
821
|
+
* honesty.
|
|
811
822
|
*/
|
|
812
823
|
substrate: z.union(["share", "agent"]).default("agent"),
|
|
813
824
|
});
|
|
@@ -1283,6 +1294,10 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1283
1294
|
* they bump the parent directory's mtime. Replaces the previous flat
|
|
1284
1295
|
* refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
|
|
1285
1296
|
*
|
|
1297
|
+
* A target that disappears mid-watch ends the loop with a report through
|
|
1298
|
+
* `changed` — never the armed-but-never-fires silence a missing directory
|
|
1299
|
+
* would otherwise produce. The scan's depth is `watchMaxDepth`.
|
|
1300
|
+
*
|
|
1286
1301
|
* @param target - the resolved file or directory to observe.
|
|
1287
1302
|
* @param changed - the invalidation callback.
|
|
1288
1303
|
* @param signal - cancels initialization; the caller closes an armed watcher.
|
|
@@ -1302,6 +1317,7 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1302
1317
|
onChange: changed,
|
|
1303
1318
|
onError: (error) => changed(error),
|
|
1304
1319
|
signal,
|
|
1320
|
+
maxDepth: this.config.watchMaxDepth,
|
|
1305
1321
|
});
|
|
1306
1322
|
}
|
|
1307
1323
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sandbox's probe and confinement logic, free of DSH peers.
|
|
3
|
+
*
|
|
4
|
+
* `lib/sandbox.js` binds this core to the peer's `SandboxUnavailableError` —
|
|
5
|
+
* the class identity `instanceof` consumers match — which is the ONLY thing
|
|
6
|
+
* that kept the whole policy out of unit tests: a checkout without the peers
|
|
7
|
+
* could not even import the module, so CI skipped every check of the probe
|
|
8
|
+
* caching policy and the argv composition. The core takes the error class as
|
|
9
|
+
* the one injected dependency; the tests inject a stand-in with the same
|
|
10
|
+
* contract (`code: "SANDBOX_UNAVAILABLE"`), and what CI runs is the code that
|
|
11
|
+
* ships.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-plugin-wsl/sandbox-core
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createProbeCache } from "./probe-cache.js";
|
|
17
|
+
import { toLinuxPath } from "./paths.js";
|
|
18
|
+
import { runCapture } from "./wsl.js";
|
|
19
|
+
import { bwrapArgvPrefix } from "./bwrap.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
|
|
23
|
+
* whose message is "Read-only file system". Classified case-insensitively.
|
|
24
|
+
*/
|
|
25
|
+
const DENIAL_SIGNATURES = ["read-only file system"];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runner-side diagnostics. Bubblewrap reports its own failures with a `bwrap: `
|
|
29
|
+
* prefix and exits 1; exit status alone never proves runner failure (the
|
|
30
|
+
* upstream rule is signature-only for exactly that reason), so a confined
|
|
31
|
+
* command that merely prints the signature is not misread as "never ran".
|
|
32
|
+
*/
|
|
33
|
+
const RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Enforcement is **partial**, not `full` like upstream's Linux rung.
|
|
37
|
+
*
|
|
38
|
+
* `SandboxMode` governs file effects. A Linux process under bwrap is fully
|
|
39
|
+
* governed — but a WSL distro process can execute a *Windows* binary through
|
|
40
|
+
* interop (`/mnt/c/**\*.exe`), and that Windows process runs outside the Linux
|
|
41
|
+
* sandbox entirely, under the user's ordinary Windows token: it can write
|
|
42
|
+
* anywhere the user can. `npm run probe:sandbox` demonstrates it. Claiming
|
|
43
|
+
* `full` would advertise a boundary that does not hold, so the provider reports
|
|
44
|
+
* the honest `partial` and README states where the hole is.
|
|
45
|
+
*/
|
|
46
|
+
const ENFORCEMENT = "partial";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one mitigation the profile can offer, and its honest ceiling.
|
|
50
|
+
*
|
|
51
|
+
* `maskWindowsDrive` mounts an empty tmpfs over `/mnt`: the Windows drive's
|
|
52
|
+
* files vanish from the command's view, which takes the drive's data off the
|
|
53
|
+
* table and makes the on-drive executables unreachable. It does NOT make the
|
|
54
|
+
* run `full`: binfmt interop dispatches on file CONTENT, so a command that can
|
|
55
|
+
* write the workspace (which `workspace-write` grants) can write a PE there
|
|
56
|
+
* and execute it — binfmt launches it as a Windows process outside the Linux
|
|
57
|
+
* sandbox regardless of where the file sits. The only complete closures are
|
|
58
|
+
* distro-level (`[interop] enabled=false` in `wsl.conf`, which costs every
|
|
59
|
+
* legitimate interop user their tooling) — so the provider reports `partial`
|
|
60
|
+
* either way, and the mask is a decision an operator makes to shrink the hole,
|
|
61
|
+
* recorded as the `windowsDrive: "masked"` fact the model can see.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
|
|
66
|
+
* failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
|
|
67
|
+
* where it is unit-testable without the peers this module needs.
|
|
68
|
+
*/
|
|
69
|
+
const probeVerdicts = createProbeCache();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the sandbox class, bound to one unavailable-error implementation.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} deps - the injected peer surface.
|
|
75
|
+
* @param {new (mode: string, detail: string) => Error} deps.SandboxUnavailableError
|
|
76
|
+
* the failure type `confine` throws; production binds the peer's class so
|
|
77
|
+
* `instanceof` consumers keep matching, tests bind a stand-in with the same
|
|
78
|
+
* `code`.
|
|
79
|
+
* @returns {typeof WslSandboxCore} the sandbox class (see `lib/sandbox.js` for
|
|
80
|
+
* the shipped binding).
|
|
81
|
+
*/
|
|
82
|
+
export function createSandboxCore({ SandboxUnavailableError }) {
|
|
83
|
+
return class WslSandboxCore {
|
|
84
|
+
/**
|
|
85
|
+
* @param config - the `wsl.exe` path, overridable for a non-standard install.
|
|
86
|
+
* @param config.maskWindowsDrive - shadow `/mnt` with an empty tmpfs in every
|
|
87
|
+
* profile this sandbox builds, hiding the Windows drive from confined
|
|
88
|
+
* commands (see the module doc above {@link ENFORCEMENT} for what that
|
|
89
|
+
* does and does not close).
|
|
90
|
+
*/
|
|
91
|
+
constructor(config = {}) {
|
|
92
|
+
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
93
|
+
this.maskWindowsDrive = config.maskWindowsDrive ?? false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether `bwrap` can create a profile inside this distro. The probe mirrors
|
|
98
|
+
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
99
|
+
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails the
|
|
100
|
+
* spawn and probes unusable, which is what makes the provider fail closed rather
|
|
101
|
+
* than silently run unconfined.
|
|
102
|
+
*
|
|
103
|
+
* A success is remembered for the process lifetime; a failure is not, so a
|
|
104
|
+
* `bubblewrap` installed while the app is running is believed on the next
|
|
105
|
+
* command.
|
|
106
|
+
*
|
|
107
|
+
* @param distro - the distro to probe.
|
|
108
|
+
* @param signal - optional cancellation.
|
|
109
|
+
* @returns true when a confined command can run there.
|
|
110
|
+
*/
|
|
111
|
+
async usable(distro, signal) {
|
|
112
|
+
const key = `${this.wslPath}\u0000${distro}`;
|
|
113
|
+
return probeVerdicts.run(key, async () => {
|
|
114
|
+
const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
|
|
115
|
+
await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Wrap one distro-side argv in the bwrap profile the policy asks for.
|
|
122
|
+
*
|
|
123
|
+
* The policy's `workspaceRoot` arrives in whichever coordinate system the
|
|
124
|
+
* caller speaks — a session workspace is a UNC path — and is translated into the
|
|
125
|
+
* Linux path `bwrap` must bind inside the distro.
|
|
126
|
+
*
|
|
127
|
+
* @param argv - the argv to run INSIDE the distro (shell, its flags, command).
|
|
128
|
+
* @param policy - the resolved confined policy (`mode` is never
|
|
129
|
+
* `danger-full-access`; the caller does not confine that case at all).
|
|
130
|
+
* @param options - the distro to run in and optional cancellation.
|
|
131
|
+
* @returns the wrapped argv plus the settlement-classification facts, in the
|
|
132
|
+
* seam's `ConfinedArgv` shape.
|
|
133
|
+
* @throws SandboxUnavailableError when the distro has no usable `bwrap`, so a
|
|
134
|
+
* requested confinement never degrades into an unconfined run.
|
|
135
|
+
*/
|
|
136
|
+
async confine(argv, policy, options) {
|
|
137
|
+
const { distro, signal } = options;
|
|
138
|
+
if (!(await this.usable(distro, signal))) {
|
|
139
|
+
throw new SandboxUnavailableError(
|
|
140
|
+
policy.mode,
|
|
141
|
+
`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
142
|
+
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
143
|
+
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
147
|
+
const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
|
|
148
|
+
return {
|
|
149
|
+
argv: [...prefix, ...argv],
|
|
150
|
+
enforcement: ENFORCEMENT,
|
|
151
|
+
denialSignatures: DENIAL_SIGNATURES,
|
|
152
|
+
runnerFailureRules: RUNNER_FAILURE_RULES,
|
|
153
|
+
windowsDrive: this.maskWindowsDrive ? "masked" : "visible",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
package/lib/sandbox.js
CHANGED
|
@@ -23,148 +23,28 @@
|
|
|
23
23
|
* The profile arguments, the denial dialect and the runner-failure rules mirror
|
|
24
24
|
* `@deepseek-ai/dsh-sandbox-local`'s `bwrap` rung, so a denial reads the same to
|
|
25
25
|
* the tool layer whether the command ran on a Linux host or inside a distro.
|
|
26
|
-
* The one deliberate difference is
|
|
26
|
+
* The one deliberate difference is the core's `partial` enforcement: see
|
|
27
|
+
* `lib/sandbox-core.js` for what it reports and why.
|
|
28
|
+
*
|
|
29
|
+
* The class itself lives in `lib/sandbox-core.js`, free of DSH peers — this
|
|
30
|
+
* module is the shipped binding that hands it the peer's `SandboxUnavailableError`,
|
|
31
|
+
* so `instanceof` consumers keep matching the class their composition knows.
|
|
27
32
|
*
|
|
28
33
|
* @module dsh-plugin-wsl/sandbox
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
|
|
32
|
-
import {
|
|
33
|
-
import { toLinuxPath } from "./paths.js";
|
|
34
|
-
import { runCapture } from "./wsl.js";
|
|
35
|
-
import { bwrapArgvPrefix, bwrapProfileArgs } from "./bwrap.js";
|
|
37
|
+
import { createSandboxCore } from "./sandbox-core.js";
|
|
36
38
|
|
|
37
39
|
// The profile and the argv prefix are also what `lib/agent-confined.js` builds
|
|
38
40
|
// for its residents, so both live in the peer-free `lib/bwrap.js` and are
|
|
39
41
|
// re-exported here, where the sandbox's callers have always found them.
|
|
40
|
-
export { bwrapProfileArgs };
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
|
|
44
|
-
* whose message is "Read-only file system". Classified case-insensitively.
|
|
45
|
-
*/
|
|
46
|
-
const DENIAL_SIGNATURES = ["read-only file system"];
|
|
42
|
+
export { bwrapProfileArgs } from "./bwrap.js";
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* upstream rule is signature-only for exactly that reason), so a confined
|
|
52
|
-
* command that merely prints the signature is not misread as "never ran".
|
|
45
|
+
* The confinement over WSL: the probe-then-confine class bound to the peer's
|
|
46
|
+
* failure type. See the core module for the probe policy and the profile.
|
|
53
47
|
*/
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Enforcement is **partial**, not `full` like upstream's Linux rung.
|
|
58
|
-
*
|
|
59
|
-
* `SandboxMode` governs file effects. A Linux process under bwrap is fully
|
|
60
|
-
* governed — but a WSL distro process can execute a *Windows* binary through
|
|
61
|
-
* interop (`/mnt/c/**\*.exe`), and that Windows process runs outside the Linux
|
|
62
|
-
* sandbox entirely, under the user's ordinary Windows token: it can write
|
|
63
|
-
* anywhere the user can. `npm run probe:sandbox` demonstrates it. Claiming
|
|
64
|
-
* `full` would advertise a boundary that does not hold, so the provider reports
|
|
65
|
-
* the honest `partial` and README states where the hole is.
|
|
66
|
-
*/
|
|
67
|
-
const ENFORCEMENT = "partial";
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The one mitigation the profile can offer, and its honest ceiling.
|
|
71
|
-
*
|
|
72
|
-
* `maskWindowsDrive` mounts an empty tmpfs over `/mnt`: the Windows drive's
|
|
73
|
-
* files vanish from the command's view, which takes the drive's data off the
|
|
74
|
-
* table and makes the on-drive executables unreachable. It does NOT make the
|
|
75
|
-
* run `full`: binfmt interop dispatches on file CONTENT, so a command that can
|
|
76
|
-
* write the workspace (which `workspace-write` grants) can write a PE there
|
|
77
|
-
* and execute it — binfmt launches it as a Windows process outside the Linux
|
|
78
|
-
* sandbox regardless of where the file sits. The only complete closures are
|
|
79
|
-
* distro-level (`[interop] enabled=false` in `wsl.conf`, which costs every
|
|
80
|
-
* legitimate interop user their tooling) — so the provider reports `partial`
|
|
81
|
-
* either way, and the mask is a decision an operator makes to shrink the hole,
|
|
82
|
-
* recorded as the `windowsDrive: "masked"` fact the model can see.
|
|
83
|
-
*/
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
|
|
87
|
-
* failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
|
|
88
|
-
* where it is unit-testable without the peers this module needs.
|
|
89
|
-
*/
|
|
90
|
-
const probeVerdicts = createProbeCache();
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Confine commands inside one distro with `bwrap`.
|
|
94
|
-
*
|
|
95
|
-
* The policy's `workspaceRoot` arrives in whichever coordinate system the
|
|
96
|
-
* caller speaks — a session workspace is a UNC path — and is translated into the
|
|
97
|
-
* Linux path `bwrap` must bind inside the distro.
|
|
98
|
-
*/
|
|
99
|
-
export class WslSandbox {
|
|
100
|
-
/**
|
|
101
|
-
* @param config - the `wsl.exe` path, overridable for a non-standard install.
|
|
102
|
-
* @param config.maskWindowsDrive - shadow `/mnt` with an empty tmpfs in every
|
|
103
|
-
* profile this sandbox builds, hiding the Windows drive from confined
|
|
104
|
-
* commands (see the module doc above {@link ENFORCEMENT} for what that
|
|
105
|
-
* does and does not close).
|
|
106
|
-
*/
|
|
107
|
-
constructor(config = {}) {
|
|
108
|
-
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
109
|
-
this.maskWindowsDrive = config.maskWindowsDrive ?? false;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Whether `bwrap` can create a profile inside this distro. The probe mirrors
|
|
114
|
-
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
115
|
-
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails the
|
|
116
|
-
* spawn and probes unusable, which is what makes the provider fail closed rather
|
|
117
|
-
* than silently run unconfined.
|
|
118
|
-
*
|
|
119
|
-
* A success is remembered for the process lifetime; a failure is not, so a
|
|
120
|
-
* `bubblewrap` installed while the app is running is believed on the next
|
|
121
|
-
* command.
|
|
122
|
-
*
|
|
123
|
-
* @param distro - the distro to probe.
|
|
124
|
-
* @param signal - optional cancellation.
|
|
125
|
-
* @returns true when a confined command can run there.
|
|
126
|
-
*/
|
|
127
|
-
async usable(distro, signal) {
|
|
128
|
-
const key = `${this.wslPath}\u0000${distro}`;
|
|
129
|
-
return probeVerdicts.run(key, async () => {
|
|
130
|
-
const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
|
|
131
|
-
await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
|
|
132
|
-
return true;
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Wrap one distro-side argv in the bwrap profile the policy asks for.
|
|
138
|
-
*
|
|
139
|
-
* @param argv - the argv to run INSIDE the distro (shell, its flags, command).
|
|
140
|
-
* @param policy - the resolved confined policy (`mode` is never
|
|
141
|
-
* `danger-full-access`; the caller does not confine that case at all).
|
|
142
|
-
* @param options - the distro to run in and optional cancellation.
|
|
143
|
-
* @returns the wrapped argv plus the settlement-classification facts, in the
|
|
144
|
-
* seam's `ConfinedArgv` shape.
|
|
145
|
-
* @throws SandboxUnavailableError when the distro has no usable `bwrap`, so a
|
|
146
|
-
* requested confinement never degrades into an unconfined run.
|
|
147
|
-
*/
|
|
148
|
-
async confine(argv, policy, options) {
|
|
149
|
-
const { distro, signal } = options;
|
|
150
|
-
if (!(await this.usable(distro, signal))) {
|
|
151
|
-
throw new SandboxUnavailableError(
|
|
152
|
-
policy.mode,
|
|
153
|
-
`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
154
|
-
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
155
|
-
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
159
|
-
const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
|
|
160
|
-
return {
|
|
161
|
-
argv: [...prefix, ...argv],
|
|
162
|
-
enforcement: ENFORCEMENT,
|
|
163
|
-
denialSignatures: DENIAL_SIGNATURES,
|
|
164
|
-
runnerFailureRules: RUNNER_FAILURE_RULES,
|
|
165
|
-
windowsDrive: this.maskWindowsDrive ? "masked" : "visible",
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
}
|
|
48
|
+
export const WslSandbox = createSandboxCore({ SandboxUnavailableError });
|
|
169
49
|
|
|
170
50
|
export default WslSandbox;
|
package/lib/watcher.js
CHANGED
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
* one `C` line per hit. No `inotifywait` dependency; the mtime walk is exact
|
|
15
15
|
* for interactive-paced edits, which is the only pace a session watch serves.
|
|
16
16
|
*
|
|
17
|
+
* The loop speaks three one-letter lines, the watcher protocol:
|
|
18
|
+
*
|
|
19
|
+
* - `R` — READY: the activation barrier; the host resolves the watch promise
|
|
20
|
+
* on it, never before.
|
|
21
|
+
* - `C` — CHANGED: something under the target may have changed.
|
|
22
|
+
* - `E` — ERROR: the watched directory no longer exists, and the loop has
|
|
23
|
+
* exited. Without it a vanished target would sit armed forever — `find`
|
|
24
|
+
* over a missing directory prints nothing, indistinguishable from "no
|
|
25
|
+
* changes" — so the loop says so and the host reports it.
|
|
26
|
+
*
|
|
17
27
|
* Known blind spot, accepted and documented: a file created with an mtime
|
|
18
28
|
* older than the stamp (`cp -p`, `tar -x`) is not seen until something else
|
|
19
29
|
* touches the tree.
|
|
@@ -29,29 +39,47 @@ import { createInterface } from "node:readline";
|
|
|
29
39
|
* quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
|
|
30
40
|
* Pure text, so it is unit-testable.
|
|
31
41
|
* @param {number} [intervalSeconds] - poll cadence; must be >= 1.
|
|
42
|
+
* @param {number} [maxDepth] - bound the scan's depth, 1 = the directory
|
|
43
|
+
* itself and its direct children; 0 (the default) scans the whole tree,
|
|
44
|
+
* which is the contract's semantics but a real cost over a tree with a
|
|
45
|
+
* `node_modules` in it.
|
|
32
46
|
* @returns {string} the `sh -c` script body.
|
|
33
47
|
*/
|
|
34
|
-
export function watchLoopScript(intervalSeconds = 2) {
|
|
48
|
+
export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
|
|
35
49
|
const seconds = Math.max(1, Math.floor(intervalSeconds));
|
|
50
|
+
const depth = Number.isInteger(maxDepth) && maxDepth >= 1 ? maxDepth : 0;
|
|
51
|
+
// A bounded walk puts `-maxdepth` directly after the path, where every find
|
|
52
|
+
// that supports it (GNU, BusyBox, BSD) accepts it as a global option.
|
|
53
|
+
const depthArgs = depth > 0 ? ` -maxdepth ${depth}` : "";
|
|
36
54
|
// `find` lists the directory itself first: a child's creation or deletion
|
|
37
55
|
// bumps the parent's mtime, so both edges are seen, not just modifications.
|
|
38
56
|
return [
|
|
39
57
|
'D="$1"',
|
|
40
58
|
'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
|
|
41
59
|
'touch "$S"',
|
|
60
|
+
// The stamp is the loop's private state, so every way out removes it. The
|
|
61
|
+
// EXIT trap covers plain exits; a signal alone would end the shell WITHOUT
|
|
62
|
+
// running it, so TERM/INT/HUP are trapped to a plain exit first.
|
|
63
|
+
"trap 'rm -f \"$S\"' EXIT",
|
|
64
|
+
"trap 'exit 0' TERM INT HUP",
|
|
42
65
|
// The READY line is the activation barrier: everything changed BEFORE it
|
|
43
66
|
// predates the stamp, so the host must not resolve the watch promise until
|
|
44
67
|
// it arrives — otherwise a write racing the first tick is invisible forever.
|
|
45
|
-
|
|
68
|
+
"printf 'R\\n'",
|
|
46
69
|
// Check first, sleep second: a change made right after activation is seen
|
|
47
70
|
// on the first cadence, not one cadence later.
|
|
48
|
-
|
|
49
|
-
' if [ -
|
|
71
|
+
"while :; do",
|
|
72
|
+
' if [ ! -d "$D" ]; then',
|
|
73
|
+
// Say so and die: silence here is the armed-forever, fires-never trap.
|
|
74
|
+
" printf 'E\\n'",
|
|
75
|
+
" exit 1",
|
|
76
|
+
" fi",
|
|
77
|
+
` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
|
|
50
78
|
' touch "$S"',
|
|
51
|
-
|
|
52
|
-
|
|
79
|
+
" printf 'C\\n'",
|
|
80
|
+
" fi",
|
|
53
81
|
` sleep ${seconds}`,
|
|
54
|
-
|
|
82
|
+
"done",
|
|
55
83
|
].join("\n");
|
|
56
84
|
}
|
|
57
85
|
|
|
@@ -63,26 +91,42 @@ export function watchLoopScript(intervalSeconds = 2) {
|
|
|
63
91
|
* @param {string} options.distro - the distro to watch inside.
|
|
64
92
|
* @param {string} options.linuxPath - the absolute Linux path to observe.
|
|
65
93
|
* @param {(error: Error) => void} [options.onError] - watcher-level failures
|
|
66
|
-
* (an exited loop); the seam reports these through its
|
|
94
|
+
* (an exited loop, a vanished target); the seam reports these through its
|
|
95
|
+
* own channel.
|
|
67
96
|
* @param {() => void} options.onChange - the invalidation callback.
|
|
68
97
|
* @param {AbortSignal} options.signal - cancels initialization; the CALLER
|
|
69
98
|
* invokes the returned close to stop an armed watcher.
|
|
70
99
|
* @param {number} [options.intervalSeconds] - poll cadence, default 2.
|
|
100
|
+
* @param {number} [options.maxDepth] - scan depth bound, 0 = the whole tree.
|
|
71
101
|
* @param {(wslPath: string, args: string[]) => import("node:child_process").ChildProcess} [options.spawn]
|
|
72
102
|
* injectable process factory for tests.
|
|
73
103
|
* @returns {Promise<() => Promise<void>>} resolves once observation is active,
|
|
74
104
|
* with the async close function the seam's contract asks for.
|
|
75
105
|
*/
|
|
76
|
-
export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, spawn: spawnProcess }) {
|
|
106
|
+
export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, maxDepth = 0, spawn: spawnProcess }) {
|
|
77
107
|
const doSpawn = spawnProcess ?? ((path, args) => spawn(path, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
|
|
78
108
|
return new Promise((resolve, reject) => {
|
|
79
109
|
if (signal?.aborted) {
|
|
80
110
|
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
81
111
|
return;
|
|
82
112
|
}
|
|
83
|
-
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds), "wsl-watch", linuxPath]);
|
|
113
|
+
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds, maxDepth), "wsl-watch", linuxPath]);
|
|
84
114
|
let settled = false;
|
|
85
115
|
let closed = false;
|
|
116
|
+
let reported = false;
|
|
117
|
+
// The loop itself writes nothing to stderr, but `sh` can (a syntax slip in
|
|
118
|
+
// a future edit) — and an undrained pipe stalls the child at the 64KB
|
|
119
|
+
// buffer limit, which would look exactly like a silently-dead watch. So
|
|
120
|
+
// the pipe is drained continuously and the tail kept for error context.
|
|
121
|
+
let stderrTail = "";
|
|
122
|
+
child.stderr?.setEncoding("utf8");
|
|
123
|
+
child.stderr?.on("data", (chunk) => {
|
|
124
|
+
stderrTail = `${stderrTail}${chunk}`.slice(-4096);
|
|
125
|
+
});
|
|
126
|
+
const describe = (message) => {
|
|
127
|
+
const detail = stderrTail.trim();
|
|
128
|
+
return detail.length > 0 ? `${message}: ${detail}` : message;
|
|
129
|
+
};
|
|
86
130
|
const lines = createInterface({ input: child.stdout });
|
|
87
131
|
lines.on("line", (line) => {
|
|
88
132
|
if (line === "R") {
|
|
@@ -92,6 +136,10 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
92
136
|
}
|
|
93
137
|
return;
|
|
94
138
|
}
|
|
139
|
+
if (line === "E") {
|
|
140
|
+
fail(new Error(describe(`the watched directory "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
95
143
|
if (line === "C") {
|
|
96
144
|
onChange();
|
|
97
145
|
}
|
|
@@ -109,6 +157,10 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
109
157
|
});
|
|
110
158
|
};
|
|
111
159
|
const fail = (error) => {
|
|
160
|
+
// One report per watcher lifetime: the loop's E line is followed by its
|
|
161
|
+
// own exit, and both must not reach the seam's error channel.
|
|
162
|
+
if (reported || closed) return;
|
|
163
|
+
reported = true;
|
|
112
164
|
if (settled) {
|
|
113
165
|
onError?.(error);
|
|
114
166
|
return;
|
|
@@ -120,8 +172,7 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
120
172
|
signal?.addEventListener("abort", () => fail(signal.reason instanceof Error ? signal.reason : new Error("aborted")), { once: true });
|
|
121
173
|
child.on("error", fail);
|
|
122
174
|
child.on("exit", (code, signalName) => {
|
|
123
|
-
|
|
175
|
+
fail(new Error(`distro watcher exited unexpectedly (code=${code} signal=${signalName ?? "none"})`));
|
|
124
176
|
});
|
|
125
|
-
|
|
126
177
|
});
|
|
127
178
|
}
|
package/lib/wsl.js
CHANGED
|
@@ -15,6 +15,18 @@ import { posixToUnc } from "./paths.js";
|
|
|
15
15
|
/** Default executable name; resolution is left to PATH. */
|
|
16
16
|
export const DEFAULT_WSL_PATH = "wsl.exe";
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The deadline every `wsl.exe` call runs under, when the caller names none.
|
|
20
|
+
*
|
|
21
|
+
* A resolution call that never returns is worse than a failed one: `wsl.exe`
|
|
22
|
+
* talks to the vmcompute service, and a wedged service would hang the tool
|
|
23
|
+
* call forever — no timeout, no error, a silent dead session. Every call now
|
|
24
|
+
* dies loudly instead. The bound is deliberately generous (a cold distro boot
|
|
25
|
+
* on a slow disk is tens of seconds), and `0` disables it for a caller that
|
|
26
|
+
* genuinely wants to wait forever.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_WSL_DEADLINE_MS = 60_000;
|
|
29
|
+
|
|
18
30
|
/**
|
|
19
31
|
* Run one host command and capture UTF-8 output.
|
|
20
32
|
*
|
|
@@ -24,16 +36,18 @@ export const DEFAULT_WSL_PATH = "wsl.exe";
|
|
|
24
36
|
*
|
|
25
37
|
* @param argv - executable plus arguments, spawned without a shell.
|
|
26
38
|
* @param signal - optional cancellation.
|
|
39
|
+
* @param deadlineMs - kill the call after this many milliseconds; the default
|
|
40
|
+
* is {@link DEFAULT_WSL_DEADLINE_MS}, and `0` waits forever.
|
|
27
41
|
* @returns captured stdout.
|
|
28
42
|
* @throws the execFile error, with a hint attached when the cause is the sandbox.
|
|
29
43
|
*/
|
|
30
|
-
export function runCapture(argv, signal) {
|
|
44
|
+
export function runCapture(argv, signal, deadlineMs = DEFAULT_WSL_DEADLINE_MS) {
|
|
31
45
|
const [file, ...args] = argv;
|
|
32
46
|
return new Promise((resolve, reject) => {
|
|
33
47
|
execFile(
|
|
34
48
|
file,
|
|
35
49
|
args,
|
|
36
|
-
{ env: { ...process.env, WSL_UTF8: "1" }, windowsHide: true, signal, encoding: "utf8" },
|
|
50
|
+
{ env: { ...process.env, WSL_UTF8: "1" }, windowsHide: true, signal, encoding: "utf8", timeout: deadlineMs },
|
|
37
51
|
(error, stdout, stderr) => {
|
|
38
52
|
if (error) {
|
|
39
53
|
// A denied WSL call is the likeliest failure here and its own message
|
|
@@ -41,6 +55,10 @@ export function runCapture(argv, signal) {
|
|
|
41
55
|
const detail = `${stdout ?? ""}${stderr ?? ""}`.replace(/\u0000/g, "").trim();
|
|
42
56
|
const denied = /E_ACCESSDENIED/i.test(detail) || error.code === "EACCES";
|
|
43
57
|
const missing = error.code === "ENOENT";
|
|
58
|
+
// The deadline kill arrives as a SIGTERM with no WSL diagnostic, so
|
|
59
|
+
// it must be named for what it is — not left looking like WSL's own
|
|
60
|
+
// failure. An abort is cancellation, not a deadline.
|
|
61
|
+
const stuck = error.killed === true && error.name !== "AbortError";
|
|
44
62
|
error.message = `${file} failed: ${detail || error.message}${
|
|
45
63
|
denied
|
|
46
64
|
? " (WSL is unreachable from a sandboxed process: DSH's Windows ACL sandbox runs commands under a restricted low-integrity token. Compose the non-sandboxing providers.)"
|
|
@@ -49,6 +67,10 @@ export function runCapture(argv, signal) {
|
|
|
49
67
|
missing
|
|
50
68
|
? ` (the executable "${file}" could not be started: check \`wslPath\` in this row, and that WSL is installed)`
|
|
51
69
|
: ""
|
|
70
|
+
}${
|
|
71
|
+
stuck
|
|
72
|
+
? ` (no completion within ${deadlineMs}ms — the WSL service may be wedged; retry once, then \`wsl.exe --shutdown\` and reopen)`
|
|
73
|
+
: ""
|
|
52
74
|
}`;
|
|
53
75
|
reject(error);
|
|
54
76
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wsl-env",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
],
|
|
64
64
|
"scripts": {
|
|
65
65
|
"test": "npm run lint:style && npm run test:syntax && npm run test:unit",
|
|
66
|
-
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-publish.test.mjs test/fs-substrate.test.mjs test/fsio-agent.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/sandbox.test.mjs test/shell.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/fs-decisions.test.mjs",
|
|
66
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-publish.test.mjs test/fs-substrate.test.mjs test/fsio-agent.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/sandbox.test.mjs test/shell.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs",
|
|
67
67
|
"test:syntax": "node test/syntax.mjs",
|
|
68
|
-
"test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/fs-publish.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/fs-decisions.test.mjs",
|
|
68
|
+
"test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/fs-publish.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs",
|
|
69
69
|
"lint:style": "node test/style.mjs",
|
|
70
70
|
"prepublishOnly": "npm test",
|
|
71
71
|
"probe": "bash test/probe/run.sh",
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"probe:sandbox-shell": "bash test/probe/sandbox-shell.sh",
|
|
79
79
|
"sync:windows": "bash test/probe/sync-to-windows.sh",
|
|
80
80
|
"probe:watch": "bash test/probe/watch.sh",
|
|
81
|
+
"probe:agent": "bash test/probe/agent.sh",
|
|
81
82
|
"probe:substrate": "bash test/probe/substrate.sh",
|
|
82
83
|
"probe:exec": "bash test/probe/exec.sh",
|
|
83
84
|
"bootstrap": "bash scripts/bootstrap.sh"
|