dsh-plugin-wsl-env 0.1.3 → 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/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.3",
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",
@@ -35,6 +35,9 @@
35
35
  "examples/**/*.yml",
36
36
  "README.md",
37
37
  "docs/README.zh.md",
38
+ "docs/CONFIGURATION.md",
39
+ "docs/LIMITATIONS.md",
40
+ "docs/ARCHITECTURE.md",
38
41
  "CHANGELOG.md",
39
42
  "CONTRIBUTING.md",
40
43
  "SECURITY.md",
@@ -43,9 +46,9 @@
43
46
  ],
44
47
  "scripts": {
45
48
  "test": "npm run lint:style && npm run test:syntax && npm run test:unit",
46
- "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",
47
50
  "test:syntax": "node test/syntax.mjs",
48
- "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",
49
52
  "lint:style": "node test/style.mjs",
50
53
  "prepublishOnly": "npm test",
51
54
  "probe": "bash test/probe/run.sh",