dsh-plugin-wsl-env 0.4.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/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;
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL preset auto-selection",
4
+ "description": "Binds the WSL preset to a new session whose workspace directory is inside a distro, and never overrides an explicit operator choice."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL preset 自动选择",
4
+ "description": "当新会话的工作目录位于发行版内时,为它绑定 WSL preset;操作者的显式选择不会被覆盖。"
5
+ }
6
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL execution environment",
4
+ "description": "Runs a DeepSeek Harness session against a WSL distro: shell, file reads/writes and command execution happen inside the distro, the directory picker and the GUI terminal target distro paths, and commands are confined by in-distro bubblewrap."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL-aware directory picker",
4
+ "description": "The workspace picker for both worlds: its root level lists the Windows home directory and every installed distro, so host and WSL workspaces share one dialog. The directory it returns is what auto-preset reads to choose the environment."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL 目录选择器",
4
+ "description": "同时服务主机与 WSL 两个世界的工作区选择器:根层级列出 Windows 主目录和每个已安装的发行版,两类工作区共用同一个对话框。它返回的目录也是 auto-preset 选择执行环境的依据。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL shell environment facts",
4
+ "description": "Contributes the WSL environment's facts (distro, real login shell, home, listening ports) to the managed DSH_* namespace injected into every model shell call — for sessions whose workspace is inside a distro."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL shell 环境事实",
4
+ "description": "把 WSL 环境的事实(发行版、真实登录 shell、home、监听端口)贡献给注入每次模型 shell 调用的 DSH_* 托管命名空间——只对工作区位于发行版内的会话生效。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL subprocess provider",
4
+ "description": "The composition-level ctx.subprocess backend: rewrites the GUI terminal's wsl.exe launch into the pinned distro and Linux directory, so the right-hand terminal window opens inside WSL."
5
+ }
6
+ }