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/CHANGELOG.md +82 -0
- package/CONTRIBUTING.md +48 -3
- package/README.md +38 -146
- package/docs/ARCHITECTURE.md +230 -0
- package/docs/CONFIGURATION.md +52 -0
- package/docs/LIMITATIONS.md +36 -0
- package/docs/README.zh.md +40 -145
- package/lib/index.js +75 -14
- package/lib/paths.js +9 -4
- package/lib/probe-cache.js +48 -0
- package/lib/sandbox.js +19 -18
- package/lib/wsl.js +19 -0
- package/package.json +6 -3
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.
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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
|
|
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
|
|
360
|
-
* home
|
|
361
|
-
*
|
|
362
|
-
*
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
/**
|
|
63
|
-
|
|
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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
}
|
|
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
|
+
"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",
|