dsh-plugin-wsl-env 0.1.4 → 0.1.5

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,68 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.1.5] - 2026-10-02
16
+
17
+ Three defects from a design-closure review. Two are cases where a documented remedy
18
+ could not work, and one where the cause of a failure was invisible.
19
+
20
+ ### Fixed
21
+
22
+ - A failed sandbox probe was remembered for the process lifetime, so installing
23
+ `bubblewrap` after the app had started changed nothing until a restart — while the
24
+ error told the user to install exactly that. Only a success is cached now; a failure
25
+ is re-probed on the next command, and concurrent first commands share one probe. The
26
+ policy lives in [`lib/probe-cache.js`](lib/probe-cache.js), which is pure, so the
27
+ regression is pinned on every platform including CI
28
+ ([`test/probe-cache.test.mjs`](test/probe-cache.test.mjs)).
29
+ - `restrictToDistro` refused another distro's share with `FS_SANDBOX_DENIED`. The
30
+ file-tool layer turns that code into a denial marker plus an escalation offer
31
+ (`mapError` in `dsh-tool-fs`), and no wider permission can lift this fence: it is a
32
+ configuration choice, not a sandbox decision. It now reports `FS_OUTSIDE_DISTRO`,
33
+ which the tool layer passes through unchanged, and the message says what to do.
34
+ - A command that never started because the distro was missing, stopped or
35
+ unregistered reached the model as a bare non-zero exit. `wsl.exe` writes
36
+ `Wsl/Service/WSL_E_DISTRO_NOT_FOUND` to **stdout** — UTF-16LE on some builds — with
37
+ an empty stderr, so every signature in the plugin missed it. `WslShellExecutor` now
38
+ reads that code (NUL-stripped, so either encoding is found), pins `WSL_UTF8` on its
39
+ own spawn so the text is readable, and fails with the distro name and the remedy.
40
+ Deliberately not a sandbox code: a wider permission cannot create a distro.
41
+
42
+ ### Documentation
43
+
44
+ - Two statements were wrong and are corrected against measurement. `read-only` does
45
+ not leave `/dev/null` as the only writable path: `bwrap --dev /dev` mounts a fresh
46
+ `/dev`, so `/dev/shm` is writable scratch and only `/tmp` stays read-only. And
47
+ `restrictToDistro` does not refuse `/mnt/c`, which is a directory *inside* the
48
+ distro — it refuses another distro's share.
49
+ - The shell row's inherited output budgets (`maxOutputBytes`, `maxSpillBytes`,
50
+ `graceMs`) are listed in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md); the
51
+ fail-closed bullet in [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md) matches the
52
+ re-probing behaviour, and what the file tools do when a distro stops mid-session is
53
+ stated there too.
54
+
55
+ ### Changed
56
+
57
+ - `test:unit` gained `test/probe-cache.test.mjs` and `test/sandbox.test.mjs`;
58
+ `test:coverage` gained the pure one. The sandbox test skips itself where the
59
+ `dsh-sandbox` peer is absent, which is the CI case, and
60
+ [`CONTRIBUTING.md`](CONTRIBUTING.md) explains when a test belongs in which list.
61
+
62
+
63
+ ### Fixed
64
+
65
+ - A shell request that names its workdir only **relatively** now lands on the
66
+ distro user's home, joined under it, when no `cwd` is configured — instead of
67
+ riding on the parent's `process.cwd()` fallback, a Windows directory no
68
+ `wsl.exe --cd` accepts. The empty-workdir case already defaulted to the home;
69
+ the relative tail was the one shape the marker missed. Absolute workdirs in
70
+ any coordinate system are unchanged.
71
+ - A Windows UNC path outside the WSL share (`\\server\share\...`) is now
72
+ treated as the world path it already is, instead of being joined onto the
73
+ default workdir as if it were relative — which named a file that existed
74
+ nowhere. It also no longer triggers a `wsl.exe` home query it can never need:
75
+ `isRelativeWorldPath` now reports every UNC form, WSL or not, as absolute.
76
+
15
77
  ## [0.1.4] - 2026-10-02
16
78
 
17
79
  Documentation only: a shorter README, and the reference documents it links to.
package/CONTRIBUTING.md CHANGED
@@ -92,9 +92,12 @@ Choose the layer by what the test needs.
92
92
  | Unit test | `test/*.test.mjs` | pure logic: path translation, listing, shell argument building, terminal helpers |
93
93
  | Probe | `test/probe/` | behaviour that needs a real distro, a real profile, or bubblewrap |
94
94
 
95
- Unit tests run under `node --test` and import only Node builtins. Add a file named
96
- `<area>.test.mjs`, then add it to both the `test:unit` and the `test:coverage`
97
- scripts in `package.json`. Both scripts list their files explicitly.
95
+ Unit tests run under `node --test`. A test that imports only this repository goes in
96
+ both the `test:unit` and the `test:coverage` lists in `package.json`, which name
97
+ their files explicitly. A test that needs a DSH peer — `test/sandbox.test.mjs`
98
+ imports `lib/sandbox.js`, which imports `@deepseek-ai/dsh-sandbox` — goes in
99
+ `test:unit` only and must skip itself when the peer is missing, because CI installs
100
+ nothing: try the import, print a `SKIP` line, exit 0.
98
101
 
99
102
  Probes are shell scripts that mount a throwaway profile, plus the small plugin
100
103
  files they load. Those plugin files are the `*-probe.mjs` and `*-probe.yml` files
@@ -141,8 +144,10 @@ On Windows 11 + WSL2 (Ubuntu 26.04) the following has been verified:
141
144
  outside the session workspace is refused inside the distro and the following
142
145
  wider-permission request succeeds.
143
146
 
144
- Counts: 42 unit assertions, 18 filesystem-probe assertions, 10 shell-probe checks, 10
145
- sandbox expectations plus the recorded escape, and 3 terminal assertions.
147
+ Counts: 50 unit assertions on a bare checkout, plus 5 in `test/sandbox.test.mjs`
148
+ which need the `dsh-sandbox` peer and skip themselves without it; 19
149
+ filesystem-probe assertions; 12 shell-probe checks; 10 sandbox expectations plus
150
+ the recorded escape; and 3 terminal assertions.
146
151
 
147
152
  ## Commit and pull request conventions
148
153
 
package/README.md CHANGED
@@ -54,7 +54,7 @@ Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys w
54
54
  | `wsl-shell` | `distro` | `''` | distro name; empty means WSL's default distro |
55
55
  | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
56
56
  | `wsl-fs` | `distro` | `''` | as above |
57
- | | `restrictToDistro` | `true` | refuse paths outside the pinned distro, including `/mnt/c` |
57
+ | | `restrictToDistro` | `true` | refuse a path in **another distro**'s share; `/mnt/c` is inside this distro and is not affected. Refused with `FS_OUTSIDE_DISTRO`, which is not a sandbox denial and cannot be lifted by wider permissions |
58
58
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
59
59
  | `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory |
60
60
  | `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
@@ -67,7 +67,7 @@ Commands are confined by `bubblewrap` inside the distro, and file writes are che
67
67
 
68
68
  | Mode | What a command inside the distro can do |
69
69
  |---|---|
70
- | `read-only` | read the whole distro; only `/dev/null` is writable |
70
+ | `read-only` | read the whole distro; a fresh `/dev` is mounted writable, so `/dev/null` and `/dev/shm` work, and nothing else does |
71
71
  | `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
72
72
  | `danger-full-access` | no sandbox; used for an approved wider-permission request |
73
73
 
@@ -120,14 +120,17 @@ behaviour and the error messages match a Linux host. The function
120
120
 
121
121
  | Mode | What a command inside the distro can do |
122
122
  |---|---|
123
- | `read-only` | read the whole distro; only `/dev/null` is writable |
123
+ | `read-only` | read the whole distro; a fresh `/dev` is mounted writable, so `/dev/null` and `/dev/shm` work, and nothing else does |
124
124
  | `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
125
125
  | `danger-full-access` | no sandbox; used for an approved wider-permission request |
126
126
 
127
127
  `WslFileSystem` checks writes against the same policy and the same writable list.
128
128
  That avoids a mismatch where `bash` could write `/tmp` but the write tool could
129
- not. Both providers report their mode through `sandboxMode`. The tool layer and the
130
- Permissions selector read that value.
129
+ not. The fence is complete for the model's reach: `writeText` and `editText` are
130
+ the only mutation calls the model-facing file tools make on the filesystem seam,
131
+ so checking those two checks everything the model can mutate. Both providers
132
+ report their mode through `sandboxMode`. The tool layer and the Permissions
133
+ selector read that value.
131
134
 
132
135
  ### Reported enforcement
133
136
 
@@ -146,6 +149,16 @@ throws `SandboxUnavailableError`, and every confined command reports
146
149
  `read-only file system` is classified as a denial, so the tool layer can offer the
147
150
  wider-permission path.
148
151
 
152
+ A failed probe is **not** remembered: only a success is cached for the process
153
+ lifetime, so a `bubblewrap` installed while the app runs is believed on the next
154
+ command. See [`lib/probe-cache.js`](../lib/probe-cache.js).
155
+
156
+ A command that never started because the distro itself is missing, stopped or
157
+ unregistered is reported separately: `wsl.exe` puts a `WSL_E_*` code on stdout with
158
+ an empty stderr, and `WslShellExecutor` turns that into an error naming the distro
159
+ and the remedy instead of a bare non-zero exit. It is deliberately not a sandbox
160
+ code, because a wider permission cannot create a distro.
161
+
149
162
  Set `sandbox: false` on either provider to opt out. Commands then run unconfined,
150
163
  and `sandboxMode` returns `undefined`.
151
164
 
@@ -18,7 +18,7 @@ are schema defaults, listed because they are the ones worth knowing.
18
18
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
19
19
  | | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
20
20
  | `wsl-fs` | `distro` | `''` *(shipped)* | as above |
21
- | | `restrictToDistro` | `true` *(shipped)* | refuse paths outside the pinned distro, including `/mnt/c` |
21
+ | | `restrictToDistro` | `true` *(shipped)* | refuse a path in **another distro**'s share. `/mnt/c` is a directory *inside* the pinned distro, so it is not affected. The refusal carries `FS_OUTSIDE_DISTRO`; that is not a sandbox decision, so the file tools do not offer a wider permission for it. Set `false` to allow another distro's share |
22
22
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
23
23
  | | `resolveSymlinks` | `true` | follow Linux symlinks that the share cannot traverse, such as `/etc/os-release` and `/bin` |
24
24
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
@@ -31,7 +31,11 @@ are schema defaults, listed because they are the ones worth knowing.
31
31
 
32
32
  You can override the other keys in the same way; they keep their defaults. Those are
33
33
  `wslPath`, `hostCwd` and `forwardEnv` on the shell and terminal rows,
34
- `diffBasisMaxBytes` on `wsl-fs`, and `distroCacheMs` on the picker.
34
+ `diffBasisMaxBytes` on `wsl-fs`, and `distroCacheMs` on the picker. The shell row
35
+ also inherits the shipped output budgets — `maxOutputBytes` (64000 bytes per
36
+ stream before the rest spills to a temporary file), `maxSpillBytes` (67108864) and
37
+ `graceMs` (3000, the SIGTERM-to-SIGKILL grace) — from `dsh-bash-local`, which
38
+ documents them in full.
35
39
 
36
40
  ## Which distro is used
37
41
 
@@ -6,7 +6,13 @@ boundary; one that is not is a bug report waiting to happen.
6
6
  - **The sandbox does not govern WSL interop.** A confined command can still run a
7
7
  Windows program, which escapes the Linux-side boundary. The plugin reports this as
8
8
  `enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
9
- - **`bubblewrap` must be installed**, or both providers fail immediately.
9
+ - **`bubblewrap` must be installed**, or both providers fail immediately. The
10
+ usability probe re-runs after a failure, so installing bubblewrap while the app
11
+ runs is believed on the next command; only a success is cached.
12
+ - A distro that is stopped or unregistered while the app runs leaves the UNC share
13
+ unreachable: the shell tools report `wsl.exe`'s `WSL_E_*` code with the reason, but
14
+ the file tools can only report a file-level error, because the share itself is
15
+ gone.
10
16
  - `workspace-write` binds the workspace root as writable, and bubblewrap refuses a
11
17
  bind whose source directory does not exist. If a session's workspace directory is
12
18
  deleted, commands fail with a runner error; the plugin does not recreate the
package/docs/README.zh.md CHANGED
@@ -54,7 +54,7 @@ dsh --profile wsl # 4. 启动
54
54
  | `wsl-shell` | `distro` | `''` | 发行版名;空表示用 WSL 的默认发行版 |
55
55
  | | `sandbox` | `true` | 用 `bubblewrap` 约束命令;`false` 表示不用沙箱 |
56
56
  | `wsl-fs` | `distro` | `''` | 同上 |
57
- | | `restrictToDistro` | `true` | 拒绝发行版之外的路径(包括 `/mnt/c`) |
57
+ | | `restrictToDistro` | `true` | 拒绝指向**其它发行版**共享的路径;`/mnt/c` 属于本发行版内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
58
58
  | | `sandbox` | `true` | 写入时按策略检查 `writeText` 和 `editText` |
59
59
  | `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
60
60
  | `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个发行版 |
@@ -67,7 +67,7 @@ dsh --profile wsl # 4. 启动
67
67
 
68
68
  | 模式 | 发行版里的命令可以做什么 |
69
69
  |---|---|
70
- | `read-only` | 整个发行版只读,只有 `/dev/null` 可写 |
70
+ | `read-only` | 整个发行版只读;`/dev` 是新建的可写挂载(`/dev/null` 与 `/dev/shm` 可用),其余不可写 |
71
71
  | `workspace-write` | 在上一行基础上,把会话工作区绑定为可写,并把 `/tmp` 挂成临时目录 |
72
72
  | `danger-full-access` | 不加沙箱;用于批准后的放宽权限请求 |
73
73
 
package/lib/index.js CHANGED
@@ -46,7 +46,7 @@ import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixTo
46
46
  import { WslSandbox } from "./sandbox.js";
47
47
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
48
48
  // can use them without pulling this file's executor and filesystem peers in.
49
- import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs } from "./wsl.js";
49
+ import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, wslErrorCode } from "./wsl.js";
50
50
 
51
51
  /**
52
52
  * Every model shell call gets a rebuilt managed `DSH_*` namespace (see
@@ -319,6 +319,11 @@ export class WslShellExecutor extends LocalBashExecutor {
319
319
  cwd: this.hostCwd(),
320
320
  env: {
321
321
  ...base.env,
322
+ // wsl.exe writes its own diagnostics as UTF-16LE unless this is set, and a
323
+ // UTF-16 message read as UTF-8 is mojibake — including the `WSL_E_*` codes
324
+ // that say why a command could not start. The distro-missing check below
325
+ // reads those codes, so the encoding has to be pinned here.
326
+ WSL_UTF8: "1",
322
327
  // WSL imports only the names listed in WSLENV. The managed DSH_* facts are
323
328
  // included by prefix so every contributed variable rides along. PATH is deliberately
324
329
  // never listed: putting it here would replace the distro's PATH with
@@ -336,9 +341,14 @@ export class WslShellExecutor extends LocalBashExecutor {
336
341
  *
337
342
  * The seam requires this step to be synchronous — the tool layer calls
338
343
  * `ctx.shell.execute(ctx.shell.resolve(…))` with no `await` between them — so
339
- * neither the home nor a confinement decision can be made here. An empty
340
- * `workdir` is the home marker, filled in {@link withDefaultWorkdir}, and the
341
- * policy travels on the spec to {@link execute}, which awaits the wrap.
344
+ * neither the home nor a confinement decision can be made here. Two request
345
+ * shapes default to the home when no `cwd` is configured: one that names no
346
+ * `workdir` at all, and one that names it only RELATIVELY — the parent
347
+ * resolves the first against `process.cwd()` (a Windows directory) and has
348
+ * nothing of its own to join the second onto. Both are stamped as the empty
349
+ * marker, with a relative tail carried verbatim in `relativeWorkdir`;
350
+ * {@link withDefaultWorkdir} resolves the pair against the home. The policy
351
+ * travels on the spec to {@link execute}, which awaits the wrap.
342
352
  *
343
353
  * @param request - the caller's request.
344
354
  * @returns the fully-resolved spec.
@@ -346,27 +356,33 @@ export class WslShellExecutor extends LocalBashExecutor {
346
356
  resolve(request) {
347
357
  const spec = super.resolve(request);
348
358
  const sandboxPolicy = this.sandbox === undefined ? undefined : (request?.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve());
349
- const needsHome = request?.workdir === undefined && configuredCwd(this.config).length === 0;
359
+ const workdir = typeof request?.workdir === "string" ? request.workdir : undefined;
360
+ const needsHome =
361
+ configuredCwd(this.config).length === 0 && (workdir === undefined || isRelativeWorldPath(workdir));
350
362
  if (!needsHome && sandboxPolicy === undefined) return spec;
351
363
  return {
352
364
  ...spec,
353
- ...(needsHome ? { workdir: "" } : {}),
365
+ ...(needsHome ? { workdir: "", relativeWorkdir: workdir } : {}),
354
366
  ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
355
367
  };
356
368
  }
357
369
 
358
370
  /**
359
- * The spec to execute, with an empty `workdir` filled from the distro user's
360
- * home. That is one `sh -c 'printf %s "$HOME"'` per provider instance, cached,
361
- * and it happens only for a request that named no directory and had no
362
- * configured default.
371
+ * The spec to execute, with the home marker resolved against the distro
372
+ * user's home: `workdir: ""` becomes the home itself, and a relative tail
373
+ * carried by {@link WslShellExecutor.resolve} is joined under it. That is one
374
+ * `sh -c 'printf %s "$HOME"'` per provider instance, cached, and it happens
375
+ * only for a request that defaulted — an absolute workdir never reaches here.
363
376
  *
364
377
  * @param spec - the resolved spec.
365
378
  * @returns the spec to execute.
366
379
  */
367
380
  async withDefaultWorkdir(spec) {
368
- if (String(spec?.workdir ?? "").trim().length > 0) return spec;
369
- return { ...spec, workdir: await distroHome(this) };
381
+ const tail = typeof spec?.relativeWorkdir === "string" ? spec.relativeWorkdir : undefined;
382
+ if (String(spec?.workdir ?? "").trim().length > 0 && tail === undefined) return spec;
383
+ const home = await distroHome(this);
384
+ if (tail === undefined || tail.length === 0) return { ...spec, workdir: home };
385
+ return { ...spec, workdir: toLinuxPath(tail, { distro: await this.distro(), cwd: home }) };
370
386
  }
371
387
 
372
388
  /**
@@ -426,13 +442,50 @@ export class WslShellExecutor extends LocalBashExecutor {
426
442
  * @param spec - resolved execution settings.
427
443
  * @returns the live shell process handle.
428
444
  */
445
+ /**
446
+ * Reject a settled command that failed because the distro itself is missing,
447
+ * stopped or unregistered.
448
+ *
449
+ * `wsl.exe` exits non-zero for that with an EMPTY stderr and its diagnostic on
450
+ * stdout, so nothing else here can see it: the denial signatures read stderr, and
451
+ * the result would otherwise reach the model as a bare non-zero code with no
452
+ * cause. The message names the remedy and says plainly that a wider permission
453
+ * cannot help — a sandbox flag does not create a distro.
454
+ *
455
+ * @param result - the settled shell result.
456
+ * @param distro - the distro the command asked for.
457
+ * @throws Error carrying the `WSL_E_*` code and the remedy.
458
+ */
459
+ throwIfDistroUnavailable(result, distro) {
460
+ if (result?.exitCode === 0) return;
461
+ const code = wslErrorCode(result?.stdout?.text, result?.stderr?.text);
462
+ if (code === undefined) return;
463
+ throw new Error(
464
+ `wsl.exe reported ${code} for distro "${distro}": it is not installed, or not running. ` +
465
+ `Start it with \`wsl.exe -d ${distro}\`, check \`wsl.exe -l -v\`, or point \`distro\` at another one. ` +
466
+ `This is not a sandbox denial, so running with wider permissions will not help.`,
467
+ );
468
+ }
469
+
429
470
  async execute(spec) {
430
471
  const distro = await this.distro();
431
472
  const shell = await this.shell();
432
473
  const resolved = await this.withDefaultWorkdir(spec);
433
474
  const policy = resolved.sandboxPolicy;
434
475
  if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
435
- return this.executeArgv(resolved, () => this.argv(resolved, distro, shell));
476
+ // `executeArgv` resolves to the execution handle, so it has to be awaited
477
+ // before it can be decorated — the confined branch below does the same.
478
+ const execution = await this.executeArgv(resolved, () => this.argv(resolved, distro, shell));
479
+ return WslShellExecutor.decorateResult(
480
+ execution,
481
+ (result) => {
482
+ this.throwIfDistroUnavailable(result, distro);
483
+ return result;
484
+ },
485
+ (error) => {
486
+ throw error;
487
+ },
488
+ );
436
489
  }
437
490
  const mode = policy.mode;
438
491
  let confined;
@@ -458,6 +511,7 @@ export class WslShellExecutor extends LocalBashExecutor {
458
511
  },
459
512
  );
460
513
  return WslShellExecutor.decorateResult(execution, (result) => {
514
+ this.throwIfDistroUnavailable(result, distro);
461
515
  if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
462
516
  const { enforcement, denialSignatures, runnerFailureRules } = confined;
463
517
  // Runner failure outranks denial because the command did not run at all.
@@ -646,7 +700,14 @@ export class WslFileSystem extends LocalFileSystem {
646
700
  }
647
701
  const world = toWorldPath(path, { distro, cwd: base });
648
702
  if (this.config.restrictToDistro && isWslUnc(world) && !isUnderDistro(world, distro)) {
649
- throw new FsError(`"${path}" is outside distro "${distro}"`, "FS_SANDBOX_DENIED");
703
+ // Deliberately NOT `FS_SANDBOX_DENIED`. The file-tool layer turns that code
704
+ // into a denial marker plus an escalation offer, and no wider permission can
705
+ // lift this fence: it is a configuration choice, not a sandbox decision.
706
+ throw new FsError(
707
+ `"${path}" names a path in another WSL distro, and this profile pins its filesystem to "${distro}" ` +
708
+ `(restrictToDistro: true). Open a session in that distro, or set restrictToDistro: false.`,
709
+ "FS_OUTSIDE_DISTRO",
710
+ );
650
711
  }
651
712
  return world;
652
713
  }
package/lib/paths.js CHANGED
@@ -46,16 +46,17 @@ export function isPosixAbsolute(value) {
46
46
  }
47
47
 
48
48
  /**
49
- * Whether a path is relative — the only input for which {@link toWorldPath} and
50
- * {@link toLinuxPath} consult their `cwd` fallback. A provider uses this to skip
51
- * resolving a default workdir for input that can never need one.
49
+ * Whether a path names a location only relative to a base. The fs provider
50
+ * consults its default workdir exactly for this input; every absolute form —
51
+ * POSIX, drive, WSL UNC, or any other UNC share — is a location in its own
52
+ * right and short-circuits before a base is ever needed.
52
53
  * @param value - candidate path.
53
54
  * @returns true when the path names no absolute location.
54
55
  */
55
56
  export function isRelativeWorldPath(value) {
56
57
  const text = String(value ?? "");
57
58
  if (text.length === 0) return false;
58
- return !isWslUnc(text) && !isPosixAbsolute(text) && !DRIVE_RE.test(text);
59
+ return !isWslUnc(text) && !isPosixAbsolute(text) && !DRIVE_RE.test(text) && !text.startsWith("\\");
59
60
  }
60
61
 
61
62
  /** Drop trailing separators, keeping a bare root (`C:\`, `\\wsl.localhost\x\`) meaningful. */
@@ -215,6 +216,10 @@ export function toWorldPath(value, options) {
215
216
  if (isWslUnc(text)) return toWindowsSlashes(text);
216
217
  if (isPosixAbsolute(text)) return posixToUnc(options?.distro ?? "Ubuntu", text);
217
218
  if (DRIVE_RE.test(text)) return toWindowsSlashes(text);
219
+ // Any other UNC share is already a world path in its own right — the host
220
+ // can open it directly, and joining it onto a base would name a file that
221
+ // exists nowhere.
222
+ if (text.startsWith("\\")) return toWindowsSlashes(text);
218
223
  const base = options?.cwd ?? process.cwd();
219
224
  const baseWorld = toWorldPath(base, { distro: options?.distro });
220
225
  return `${baseWorld.replace(/[\\/]+$/, "")}\\${toWindowsSlashes(text).replace(/^\.\\/, "")}`;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The caching policy for a functional probe: which verdicts may be remembered.
3
+ *
4
+ * A SUCCESS is durable — if `bwrap` created a profile a minute ago it will create
5
+ * one now. A FAILURE is not: it describes the machine as it is at that moment, and
6
+ * the common case is a package that is not installed *yet*. Remembering a failure
7
+ * would make the documented remedy (`sudo apt install bubblewrap`) silently
8
+ * ineffective until the app restarts, while the error message tells the user to
9
+ * install exactly that. So a failure is dropped and the next call probes again.
10
+ *
11
+ * Probes are I/O, and concurrent first calls would otherwise each pay for one.
12
+ * Storing the in-flight promise makes them share a single probe.
13
+ *
14
+ * Pure by construction — no Node API, no clock — so the policy is unit-testable
15
+ * without the peers `lib/sandbox.js` needs.
16
+ *
17
+ * @param {() => Promise<boolean>|boolean} probe - the probe to run on a miss. A
18
+ * throw is a failed probe, never a propagated error.
19
+ * @returns {{ run: (key: string, probe: () => Promise<boolean>|boolean) => Promise<boolean>, size: number }}
20
+ */
21
+ export function createProbeCache() {
22
+ /** @type {Map<string, true|Promise<boolean>>} */
23
+ const verdicts = new Map();
24
+ return {
25
+ async run(key, probe) {
26
+ const cached = verdicts.get(key);
27
+ if (cached !== undefined) return cached;
28
+
29
+ const pending = (async () => {
30
+ try {
31
+ return await probe();
32
+ } catch {
33
+ return false;
34
+ }
35
+ })();
36
+ verdicts.set(key, pending);
37
+
38
+ const verdict = await pending;
39
+ if (verdict) verdicts.set(key, true);
40
+ else verdicts.delete(key);
41
+ return verdict;
42
+ },
43
+ /** Remembered successes, for tests and diagnostics. */
44
+ get size() {
45
+ return verdicts.size;
46
+ },
47
+ };
48
+ }
package/lib/sandbox.js CHANGED
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
32
+ import { createProbeCache } from "./probe-cache.js";
32
33
  import { toLinuxPath } from "./paths.js";
33
34
  import { runCapture } from "./wsl.js";
34
35
 
@@ -59,8 +60,12 @@ const RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
59
60
  */
60
61
  const ENFORCEMENT = "partial";
61
62
 
62
- /** Probe verdicts, keyed by `wslPath\0distro`; the probe spawns once per pair. */
63
- const probeVerdicts = new Map();
63
+ /**
64
+ * Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
65
+ * failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
66
+ * where it is unit-testable without the peers this module needs.
67
+ */
68
+ const probeVerdicts = createProbeCache();
64
69
 
65
70
  /**
66
71
  * Build the bwrap profile arguments for one file-effect policy, exactly as
@@ -101,12 +106,15 @@ export class WslSandbox {
101
106
  }
102
107
 
103
108
  /**
104
- * Whether `bwrap` can create a profile inside this distro, probed once per
105
- * (`wslPath`, distro) pair. The probe mirrors upstream's: apply the real
106
- * read-only profile around `true`, and treat a zero exit as the kernel having
107
- * accepted and enforced it. A missing `bwrap` fails the spawn and probes
108
- * unusable, which is what makes the provider fail closed rather than silently
109
- * run unconfined.
109
+ * Whether `bwrap` can create a profile inside this distro. The probe mirrors
110
+ * upstream's: apply the real read-only profile around `true`, and treat a zero
111
+ * exit as the kernel having accepted and enforced it. A missing `bwrap` fails the
112
+ * spawn and probes unusable, which is what makes the provider fail closed rather
113
+ * than silently run unconfined.
114
+ *
115
+ * A success is remembered for the process lifetime; a failure is not, so a
116
+ * `bubblewrap` installed while the app is running is believed on the next
117
+ * command.
110
118
  *
111
119
  * @param distro - the distro to probe.
112
120
  * @param signal - optional cancellation.
@@ -114,18 +122,11 @@ export class WslSandbox {
114
122
  */
115
123
  async usable(distro, signal) {
116
124
  const key = `${this.wslPath}\u0000${distro}`;
117
- const cached = probeVerdicts.get(key);
118
- if (cached !== undefined) return cached;
119
- let verdict = false;
120
- try {
125
+ return probeVerdicts.run(key, async () => {
121
126
  const profile = bwrapProfileArgs({ mode: "read-only", workspaceRoot: "/" });
122
127
  await runCapture([this.wslPath, "-d", distro, "--exec", "bwrap", ...profile, "--", "true"], signal);
123
- verdict = true;
124
- } catch {
125
- verdict = false;
126
- }
127
- probeVerdicts.set(key, verdict);
128
- return verdict;
128
+ return true;
129
+ });
129
130
  }
130
131
 
131
132
  /**
package/lib/wsl.js CHANGED
@@ -285,6 +285,25 @@ export function wslEnvValue(names) {
285
285
  * @param options - the `wsl.exe` path and optional cancellation.
286
286
  * @returns an absolute path to the shell inside the distro.
287
287
  */
288
+ /**
289
+ * The `WSL_E_*` code inside `wsl.exe`'s own output, when it reported one.
290
+ *
291
+ * wsl.exe writes these diagnostics to STDOUT, and a build may encode them as
292
+ * UTF-16LE — a NUL byte between every ASCII character. Read as UTF-8 that leaves
293
+ * the code spelled with NULs in the middle, so the search strips them first and
294
+ * finds the code under either encoding. Verified against a missing distro:
295
+ * `wsl.exe -d nosuchdistro --exec …` exits 255 with an empty stderr and
296
+ * `Wsl/Service/WSL_E_DISTRO_NOT_FOUND` on stdout.
297
+ *
298
+ * @param stdout - a command's captured stdout.
299
+ * @param stderr - its captured stderr.
300
+ * @returns the code, for example `WSL_E_DISTRO_NOT_FOUND`, or undefined.
301
+ */
302
+ export function wslErrorCode(stdout, stderr) {
303
+ const text = `${stdout ?? ""}\n${stderr ?? ""}`.replace(/\0/g, "");
304
+ return /WSL_E_[A-Z0-9_]+/.exec(text)?.[0];
305
+ }
306
+
288
307
  export async function defaultShell(distro, options = {}) {
289
308
  const wslPath = options.wslPath ?? DEFAULT_WSL_PATH;
290
309
  const ask = async (script) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Run a DeepSeek Harness session against a WSL distro: a ctx.shell executor that runs commands inside the distro and a ctx.fs backend over its UNC share",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -46,9 +46,9 @@
46
46
  ],
47
47
  "scripts": {
48
48
  "test": "npm run lint:style && npm run test:syntax && npm run test:unit",
49
- "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",
49
+ "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",
50
50
  "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",
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/sandbox.test.mjs",
52
52
  "lint:style": "node test/style.mjs",
53
53
  "prepublishOnly": "npm test",
54
54
  "probe": "bash test/probe/run.sh",