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 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)
@@ -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
- kill -TERM "$CURRENT_PID" 2>/dev/null
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; a process that
96
- # ignores TERM gets KILLed after the grace, so a stuck command cannot hang
97
- # the request forever.
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
- kill -TERM "$2" 2>/dev/null
128
+ kill_request TERM "$2"
100
129
  sleep "${3:-3}"
101
- kill -KILL "$2" 2>/dev/null
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; `subshell` runs ( "$@" ) in a forked subshell, which is how
108
- # the FS handlers — shell functions, not executables — run.
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
@@ -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 (default), or the resident in-distro agent on ext4. |
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/sandbox.js`](../lib/sandbox.js) builds them.
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` | `watch()` is refused, because a 9p share cannot be watched reliably | poll, or watch from inside the distro |
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
@@ -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 and watches run where the files live, symlinks and mode bits
804
- * are native, and the mutation guard survives to the write's no-replace
805
- * publication, kernel-enforced by the confined resident when the policy is
806
- * confined. `"share"` — the opt-out: the Windows-side host filesystem
807
- * stack over the distro's 9p share, with the symlink-retry and publication
808
- * hooks that compensate for what the share cannot do. Both substrates
809
- * fence writes with the same host-side policy check; neither changes
810
- * `sandboxMode`'s honesty.
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 {@link ENFORCEMENT}: see its doc.
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 { createProbeCache } from "./probe-cache.js";
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
- * Runner-side diagnostics. Bubblewrap reports its own failures with a `bwrap: `
50
- * prefix and exits 1; exit status alone never proves runner failure (the
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 RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
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
- 'printf \'R\\n\'',
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
- 'while :; do',
49
- ' if [ -n "$(find "$D" -newer "$S" -print -quit 2>/dev/null)" ]; then',
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
- ' printf \'C\\n\'',
52
- ' fi',
79
+ " printf 'C\\n'",
80
+ " fi",
53
81
  ` sleep ${seconds}`,
54
- 'done',
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 own channel.
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
- if (!closed) fail(new Error(`distro watcher exited unexpectedly (code=${code} signal=${signalName ?? "none"})`));
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.5.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"