dsh-plugin-wsl-env 0.1.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/sandbox.js CHANGED
@@ -32,6 +32,12 @@ import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
32
32
  import { createProbeCache } from "./probe-cache.js";
33
33
  import { toLinuxPath } from "./paths.js";
34
34
  import { runCapture } from "./wsl.js";
35
+ import { bwrapArgvPrefix, bwrapProfileArgs } from "./bwrap.js";
36
+
37
+ // The profile and the argv prefix are also what `lib/agent-confined.js` builds
38
+ // for its residents, so both live in the peer-free `lib/bwrap.js` and are
39
+ // re-exported here, where the sandbox's callers have always found them.
40
+ export { bwrapProfileArgs };
35
41
 
36
42
  /**
37
43
  * bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
@@ -60,6 +66,22 @@ const RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
60
66
  */
61
67
  const ENFORCEMENT = "partial";
62
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
+
63
85
  /**
64
86
  * Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
65
87
  * failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
@@ -67,29 +89,6 @@ const ENFORCEMENT = "partial";
67
89
  */
68
90
  const probeVerdicts = createProbeCache();
69
91
 
70
- /**
71
- * Build the bwrap profile arguments for one file-effect policy, exactly as
72
- * upstream's `bwrapProfileArgs` does.
73
- *
74
- * `--ro-bind / /` makes the whole distro read-only, `--dev /dev` supplies the
75
- * `/dev/null` sink a shell needs, `--unshare-pid` keeps the sandbox's process
76
- * view its own, and `--proc /proc` gives it a matching `/proc`. Under
77
- * `workspace-write` the workspace is bound read-write and `/tmp` becomes an
78
- * ephemeral tmpfs — the temp area the mode promises, without exposing the
79
- * distro's real `/tmp`.
80
- *
81
- * @param policy - the policy to express as bwrap mounts.
82
- * @returns profile arguments, before the `--` separator and the command argv.
83
- */
84
- export function bwrapProfileArgs(policy) {
85
- const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
86
- if (policy.mode === "workspace-write") {
87
- args.push("--tmpfs", "/tmp");
88
- args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
89
- }
90
- return args;
91
- }
92
-
93
92
  /**
94
93
  * Confine commands inside one distro with `bwrap`.
95
94
  *
@@ -100,9 +99,14 @@ export function bwrapProfileArgs(policy) {
100
99
  export class WslSandbox {
101
100
  /**
102
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).
103
106
  */
104
107
  constructor(config = {}) {
105
108
  this.wslPath = config.wslPath ?? "wsl.exe";
109
+ this.maskWindowsDrive = config.maskWindowsDrive ?? false;
106
110
  }
107
111
 
108
112
  /**
@@ -123,8 +127,8 @@ export class WslSandbox {
123
127
  async usable(distro, signal) {
124
128
  const key = `${this.wslPath}\u0000${distro}`;
125
129
  return probeVerdicts.run(key, async () => {
126
- const profile = bwrapProfileArgs({ mode: "read-only", workspaceRoot: "/" });
127
- await runCapture([this.wslPath, "-d", distro, "--exec", "bwrap", ...profile, "--", "true"], signal);
130
+ const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
131
+ await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
128
132
  return true;
129
133
  });
130
134
  }
@@ -146,16 +150,19 @@ export class WslSandbox {
146
150
  if (!(await this.usable(distro, signal))) {
147
151
  throw new SandboxUnavailableError(
148
152
  policy.mode,
149
- `bwrap is not usable inside distro "${distro}" (install it: sudo apt install bubblewrap)`,
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`,
150
156
  );
151
157
  }
152
158
  const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
153
- const profile = bwrapProfileArgs({ ...policy, workspaceRoot });
159
+ const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
154
160
  return {
155
- argv: ["bwrap", ...profile, "--", ...argv],
161
+ argv: [...prefix, ...argv],
156
162
  enforcement: ENFORCEMENT,
157
163
  denialSignatures: DENIAL_SIGNATURES,
158
164
  runnerFailureRules: RUNNER_FAILURE_RULES,
165
+ windowsDrive: this.maskWindowsDrive ? "masked" : "visible",
159
166
  };
160
167
  }
161
168
  }
package/lib/shell-env.js CHANGED
@@ -26,6 +26,8 @@
26
26
  import z from "@deepseek-ai/schemastery";
27
27
  import { isWslUnc } from "./paths.js";
28
28
  import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
29
+ import { sharedAgent } from "./agent-shared.js";
30
+ import { listeningPorts } from "./ports.js";
29
31
 
30
32
  export const name = "wsl-shell-env";
31
33
 
@@ -34,6 +36,8 @@ export const inject = ["shellEnv"];
34
36
  export const Config = z.object({
35
37
  /** Distro these facts describe; empty means this machine's default distro. */
36
38
  distro: z.string().default(""),
39
+ /** How often the listening-port snapshot refreshes, in milliseconds. */
40
+ portsRefreshMs: z.number().default(10 * 1000),
37
41
  });
38
42
 
39
43
  export async function apply(ctx, config = {}) {
@@ -56,7 +60,21 @@ export async function apply(ctx, config = {}) {
56
60
  DSH_WSL_HOME: {
57
61
  description: `Home directory of the user model shell calls run as inside ${distro}.`,
58
62
  },
63
+ DSH_WSL_PORTS: {
64
+ description: `Ports with a listener inside ${distro} (comma-separated, ascending), as of the last snapshot — refreshed every ${Math.round(config.portsRefreshMs / 1000)}s, so a dev server started moments ago may not be listed yet. Empty means none were listening at the last scan.`,
65
+ },
66
+ };
67
+
68
+ // The port snapshot refreshes on a timer and is served synchronously: the
69
+ // registry's resolve is sync by contract, so live reads are impossible —
70
+ // the timer bounds staleness instead (see lib/ports.js).
71
+ let ports = [];
72
+ const refreshPorts = async () => {
73
+ ports = await listeningPorts(sharedAgent(distro), 8000);
59
74
  };
75
+ void refreshPorts();
76
+ const portsTimer = setInterval(() => void refreshPorts(), config.portsRefreshMs);
77
+ portsTimer.unref?.();
60
78
 
61
79
  const dispose = ctx.shellEnv.register({
62
80
  name: "dsh-plugin-wsl-env",
@@ -64,11 +82,19 @@ export async function apply(ctx, config = {}) {
64
82
  resolve: (execution) => {
65
83
  const cwd = execution?.agent?.session?.header?.cwd;
66
84
  if (!isWslUnc(cwd)) return {};
67
- return { DSH_WSL_DISTRO: distro, DSH_WSL_SHELL: shell, DSH_WSL_HOME: home };
85
+ return {
86
+ DSH_WSL_DISTRO: distro,
87
+ DSH_WSL_SHELL: shell,
88
+ DSH_WSL_HOME: home,
89
+ DSH_WSL_PORTS: ports.join(","),
90
+ };
68
91
  },
69
92
  });
70
93
 
71
94
  // Disposing matters: a config hot-reload re-runs apply(), and the registry
72
95
  // rejects a second contributor claiming the same keys.
73
- ctx.effect(() => dispose);
96
+ ctx.effect(() => {
97
+ clearInterval(portsTimer);
98
+ dispose();
99
+ });
74
100
  }
package/lib/subprocess.js CHANGED
@@ -74,6 +74,7 @@ import z from "@deepseek-ai/schemastery";
74
74
  import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
75
75
  import { isPosixAbsolute, toLinuxPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
76
76
  import { DEFAULT_WSL_PATH, defaultDistro, wslEnvValue, wslTerminalArgv } from "./wsl.js";
77
+ import { terminalRoute } from "./terminal-route.js";
77
78
 
78
79
  /**
79
80
  * Env names a terminal launch keeps for the distro. `TERM` rides along because
@@ -120,6 +121,13 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
120
121
  hostCwd: z.string().volatile(),
121
122
  /** Extra env names to forward into the distro through WSLENV. */
122
123
  forwardEnv: z.array(z.string()).default([]),
124
+ /**
125
+ * Give a session opened on a WINDOWS folder a host shell instead of the
126
+ * distro's shell started at `/mnt/<drive>/…`. WSL-folder sessions always
127
+ * get the distro shell — that is this provider's purpose. `false` restores
128
+ * the composition-owned terminal for every session.
129
+ */
130
+ hostSessions: z.boolean().default(true),
123
131
  });
124
132
 
125
133
  constructor(ctx, config) {
@@ -185,6 +193,13 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
185
193
  */
186
194
  async spawnTerminal(spec) {
187
195
  if (!this.launchesDistro(spec.argv)) return super.spawnTerminal(spec);
196
+ // A session on a Windows folder is a HOST session: it gets the host shell
197
+ // in its own directory, not the distro's shell at `/mnt/<drive>/…`. The
198
+ // wsl.exe argv still names this provider's launch, so the substitution
199
+ // stays inside the override — every other consumer is untouched.
200
+ if (this.config.hostSessions && terminalRoute(spec.cwd) === "host") {
201
+ return super.spawnTerminal({ ...spec, argv: ["powershell.exe", "-NoLogo"] });
202
+ }
188
203
  const target = await this.terminalTarget(spec.cwd);
189
204
  const argv = wslTerminalArgv({
190
205
  wslPath: this.config.wslPath,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Which execution world a terminal launch belongs to.
3
+ *
4
+ * The terminal controller hands this provider the request's directory, and the
5
+ * directory's coordinate system is the evidence of intent: a UNC path under a
6
+ * distro's share names a WSL session outright, a POSIX absolute path is a
7
+ * distro path by construction, and a Windows drive path is a session opened on
8
+ * a Windows folder — which deserves a HOST shell, not the distro's shell
9
+ * started at `/mnt/<drive>/…`. An empty directory carries no evidence either
10
+ * way and defaults to the distro, which is what a profile pins.
11
+ *
12
+ * Pure by construction.
13
+ *
14
+ * @module dsh-plugin-wsl/terminal-route
15
+ */
16
+
17
+ import { isPosixAbsolute, uncToPosix, windowsToLinuxMount } from "./paths.js";
18
+
19
+ /**
20
+ * @param cwd - the terminal request's working directory, any coordinate system.
21
+ * @returns `"distro"` when the launch belongs inside the pinned distro,
22
+ * `"host"` when it belongs to a Windows-folder session.
23
+ */
24
+ export function terminalRoute(cwd) {
25
+ const text = String(cwd ?? "").trim();
26
+ if (text.length === 0) return "distro";
27
+ if (uncToPosix(text) !== undefined) return "distro";
28
+ if (isPosixAbsolute(text)) return "distro";
29
+ if (windowsToLinuxMount(text) !== undefined) return "host";
30
+ return "distro";
31
+ }
package/lib/watcher.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * In-distro file watching for `WslFileSystem.watch`.
3
+ *
4
+ * A 9p share gives Node no usable change notification — chokidar over
5
+ * `\\wsl.localhost` is the unreliability that made this provider refuse to
6
+ * watch at all. The seam's contract, however, asks for far less than events:
7
+ * `watch(target, changed, signal)` wants ONE invalidation callback, invoked
8
+ * whenever something under `target` may have changed. That is cheap to honor
9
+ * from inside the distro, where the kernel does know.
10
+ *
11
+ * The watcher is a long-lived `wsl.exe` shell loop: periodically
12
+ * `find <dir> -newer <stamp>` — the directory itself is in the scan, so
13
+ * creations AND deletions (which bump the parent's mtime) are both seen — and
14
+ * one `C` line per hit. No `inotifywait` dependency; the mtime walk is exact
15
+ * for interactive-paced edits, which is the only pace a session watch serves.
16
+ *
17
+ * Known blind spot, accepted and documented: a file created with an mtime
18
+ * older than the stamp (`cp -p`, `tar -x`) is not seen until something else
19
+ * touches the tree.
20
+ *
21
+ * @module dsh-plugin-wsl/watcher
22
+ */
23
+
24
+ import { spawn } from "node:child_process";
25
+ import { createInterface } from "node:readline";
26
+
27
+ /**
28
+ * The shell loop the watcher runs, parameterised by argv so no path can break
29
+ * quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
30
+ * Pure text, so it is unit-testable.
31
+ * @param {number} [intervalSeconds] - poll cadence; must be >= 1.
32
+ * @returns {string} the `sh -c` script body.
33
+ */
34
+ export function watchLoopScript(intervalSeconds = 2) {
35
+ const seconds = Math.max(1, Math.floor(intervalSeconds));
36
+ // `find` lists the directory itself first: a child's creation or deletion
37
+ // bumps the parent's mtime, so both edges are seen, not just modifications.
38
+ return [
39
+ 'D="$1"',
40
+ 'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
41
+ 'touch "$S"',
42
+ // The READY line is the activation barrier: everything changed BEFORE it
43
+ // predates the stamp, so the host must not resolve the watch promise until
44
+ // it arrives — otherwise a write racing the first tick is invisible forever.
45
+ 'printf \'R\\n\'',
46
+ // Check first, sleep second: a change made right after activation is seen
47
+ // on the first cadence, not one cadence later.
48
+ 'while :; do',
49
+ ' if [ -n "$(find "$D" -newer "$S" -print -quit 2>/dev/null)" ]; then',
50
+ ' touch "$S"',
51
+ ' printf \'C\\n\'',
52
+ ' fi',
53
+ ` sleep ${seconds}`,
54
+ 'done',
55
+ ].join("\n");
56
+ }
57
+
58
+ /**
59
+ * Arm one in-distro watcher over a Linux directory.
60
+ *
61
+ * @param {object} options - the watch request.
62
+ * @param {string} options.wslPath - the `wsl.exe` path.
63
+ * @param {string} options.distro - the distro to watch inside.
64
+ * @param {string} options.linuxPath - the absolute Linux path to observe.
65
+ * @param {(error: Error) => void} [options.onError] - watcher-level failures
66
+ * (an exited loop); the seam reports these through its own channel.
67
+ * @param {() => void} options.onChange - the invalidation callback.
68
+ * @param {AbortSignal} options.signal - cancels initialization; the CALLER
69
+ * invokes the returned close to stop an armed watcher.
70
+ * @param {number} [options.intervalSeconds] - poll cadence, default 2.
71
+ * @param {(wslPath: string, args: string[]) => import("node:child_process").ChildProcess} [options.spawn]
72
+ * injectable process factory for tests.
73
+ * @returns {Promise<() => Promise<void>>} resolves once observation is active,
74
+ * with the async close function the seam's contract asks for.
75
+ */
76
+ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, spawn: spawnProcess }) {
77
+ const doSpawn = spawnProcess ?? ((path, args) => spawn(path, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
78
+ return new Promise((resolve, reject) => {
79
+ if (signal?.aborted) {
80
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
81
+ return;
82
+ }
83
+ const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds), "wsl-watch", linuxPath]);
84
+ let settled = false;
85
+ let closed = false;
86
+ const lines = createInterface({ input: child.stdout });
87
+ lines.on("line", (line) => {
88
+ if (line === "R") {
89
+ if (!settled && !signal?.aborted) {
90
+ settled = true;
91
+ resolve(close);
92
+ }
93
+ return;
94
+ }
95
+ if (line === "C") {
96
+ onChange();
97
+ }
98
+ });
99
+ const close = async () => {
100
+ if (closed) return;
101
+ closed = true;
102
+ child.removeAllListeners("exit");
103
+ lines.close();
104
+ child.stdout.destroy();
105
+ child.kill();
106
+ await new Promise((resolveClose) => {
107
+ if (child.exitCode !== null || child.signalCode !== null) return resolveClose();
108
+ child.once("exit", resolveClose);
109
+ });
110
+ };
111
+ const fail = (error) => {
112
+ if (settled) {
113
+ onError?.(error);
114
+ return;
115
+ }
116
+ settled = true;
117
+ void close();
118
+ reject(error);
119
+ };
120
+ signal?.addEventListener("abort", () => fail(signal.reason instanceof Error ? signal.reason : new Error("aborted")), { once: true });
121
+ child.on("error", fail);
122
+ child.on("exit", (code, signalName) => {
123
+ if (!closed) fail(new Error(`distro watcher exited unexpectedly (code=${code} signal=${signalName ?? "none"})`));
124
+ });
125
+
126
+ });
127
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.1.10",
4
- "description": "Run a DeepSeek Harness session against a WSL distro: commands and the model's file tools run inside it, the folder picker and the GUI terminal open there, and commands are confined by bubblewrap inside the distro",
3
+ "version": "0.3.0",
4
+ "description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",
@@ -22,6 +22,7 @@
22
22
  "./shell-env": "./lib/shell-env.js",
23
23
  "./wsl": "./lib/wsl.js",
24
24
  "./paths": "./lib/paths.js",
25
+ "./agent": "./lib/agent.js",
25
26
  "./package.json": "./package.json"
26
27
  },
27
28
  "dsh": {
@@ -31,12 +32,16 @@
31
32
  },
32
33
  "files": [
33
34
  "lib/**/*.js",
35
+ "agent/*.sh",
34
36
  "cordis.patch.yml",
35
37
  "examples/**/*.yml",
36
38
  "README.md",
37
39
  "docs/README.zh.md",
38
40
  "docs/CONFIGURATION.md",
39
41
  "docs/LIMITATIONS.md",
42
+ "docs/PARITY.md",
43
+ "docs/PEER-PARITY.md",
44
+ "docs/UPSTREAM-FSIO-EXPORT.md",
40
45
  "docs/ARCHITECTURE.md",
41
46
  "CHANGELOG.md",
42
47
  "CONTRIBUTING.md",
@@ -48,7 +53,7 @@
48
53
  "test": "npm run lint:style && npm run test:syntax && npm run test:unit",
49
54
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 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",
50
55
  "test:syntax": "node test/syntax.mjs",
51
- "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",
56
+ "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",
52
57
  "lint:style": "node test/style.mjs",
53
58
  "prepublishOnly": "npm test",
54
59
  "probe": "bash test/probe/run.sh",
@@ -59,7 +64,11 @@
59
64
  "probe:terminal": "bash test/probe/terminal.sh",
60
65
  "probe:sandbox": "bash test/probe/sandbox.sh",
61
66
  "probe:sandbox-shell": "bash test/probe/sandbox-shell.sh",
62
- "sync:windows": "bash test/probe/sync-to-windows.sh"
67
+ "sync:windows": "bash test/probe/sync-to-windows.sh",
68
+ "probe:watch": "bash test/probe/watch.sh",
69
+ "probe:substrate": "bash test/probe/substrate.sh",
70
+ "probe:exec": "bash test/probe/exec.sh",
71
+ "bootstrap": "bash scripts/bootstrap.sh"
63
72
  },
64
73
  "engines": {
65
74
  "node": ">=20"