dsh-plugin-wsl-env 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +43 -17
- package/agent/wsl-agent.sh +41 -8
- package/cordis.patch.yml +6 -4
- package/docs/ARCHITECTURE.md +11 -6
- package/docs/CONFIGURATION.md +2 -0
- package/docs/LIMITATIONS.md +10 -0
- package/docs/PARITY.md +4 -0
- package/docs/README.zh.md +35 -10
- package/docs/UPSTREAM-TERMINAL-TITLE.md +81 -0
- package/lib/index.js +26 -10
- package/lib/sandbox-core.js +157 -0
- package/lib/sandbox.js +11 -131
- package/lib/watcher.js +63 -12
- package/lib/wsl.js +24 -2
- package/locale/auto-preset/en.json +6 -0
- package/locale/auto-preset/zh.json +6 -0
- package/locale/en.json +6 -0
- package/locale/picker/en.json +6 -0
- package/locale/picker/zh.json +6 -0
- package/locale/shell-env/en.json +6 -0
- package/locale/shell-env/zh.json +6 -0
- package/locale/subprocess/en.json +6 -0
- package/locale/subprocess/zh.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +17 -4
package/lib/index.js
CHANGED
|
@@ -580,7 +580,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
580
580
|
async executeViaAgent(resolved, distro, shell) {
|
|
581
581
|
const policy = resolved.sandboxPolicy;
|
|
582
582
|
const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
|
|
583
|
-
? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro })
|
|
583
|
+
? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro, signal: resolved.signal })
|
|
584
584
|
: undefined;
|
|
585
585
|
const mode = policy?.mode;
|
|
586
586
|
const execution = agentExecutionHandle({
|
|
@@ -788,6 +788,15 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
788
788
|
* distinct failing path, cached.
|
|
789
789
|
*/
|
|
790
790
|
resolveSymlinks: z.boolean().default(true),
|
|
791
|
+
/**
|
|
792
|
+
* How deep the in-distro watch scan walks, `1` = the watched directory and
|
|
793
|
+
* its direct children. `0` — the default — walks the whole tree, which is
|
|
794
|
+
* what the invalidation contract asks for but a real cost every poll over
|
|
795
|
+
* a tree containing a `node_modules`; a bound trades that cost for a
|
|
796
|
+
* documented blind spot: a change below the bound is not seen until
|
|
797
|
+
* another change above it fires the same callback.
|
|
798
|
+
*/
|
|
799
|
+
watchMaxDepth: z.natural().default(0),
|
|
791
800
|
/**
|
|
792
801
|
* Run the in-distro side of publication (mode copy + atomic rename) through
|
|
793
802
|
* the resident agent (`agent/wsl-agent.sh`), one round trip instead of a
|
|
@@ -799,15 +808,17 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
799
808
|
agent: z.boolean().default(true),
|
|
800
809
|
/**
|
|
801
810
|
* Which I/O substrate serves the file tools. `"agent"` (the default) —
|
|
802
|
-
* the resident in-distro agent (`lib/fs-substrate.js`): reads, writes
|
|
803
|
-
* identities
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
* `
|
|
811
|
+
* the resident in-distro agent (`lib/fs-substrate.js`): reads, writes and
|
|
812
|
+
* identities run where the files live, symlinks and mode bits are native,
|
|
813
|
+
* and the mutation guard survives to the write's no-replace publication,
|
|
814
|
+
* kernel-enforced by the confined resident when the policy is confined.
|
|
815
|
+
* `"share"` — the opt-out: the Windows-side host filesystem stack over
|
|
816
|
+
* the distro's 9p share, with the symlink-retry and publication hooks
|
|
817
|
+
* that compensate for what the share cannot do. Both substrates fence
|
|
818
|
+
* writes with the same host-side policy check, and `watch()` rides the
|
|
819
|
+
* same in-distro poll loop for both (`lib/watcher.js`) — it never runs
|
|
820
|
+
* through either substrate. Neither substrate changes `sandboxMode`'s
|
|
821
|
+
* honesty.
|
|
811
822
|
*/
|
|
812
823
|
substrate: z.union(["share", "agent"]).default("agent"),
|
|
813
824
|
});
|
|
@@ -1283,6 +1294,10 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1283
1294
|
* they bump the parent directory's mtime. Replaces the previous flat
|
|
1284
1295
|
* refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
|
|
1285
1296
|
*
|
|
1297
|
+
* A target that disappears mid-watch ends the loop with a report through
|
|
1298
|
+
* `changed` — never the armed-but-never-fires silence a missing directory
|
|
1299
|
+
* would otherwise produce. The scan's depth is `watchMaxDepth`.
|
|
1300
|
+
*
|
|
1286
1301
|
* @param target - the resolved file or directory to observe.
|
|
1287
1302
|
* @param changed - the invalidation callback.
|
|
1288
1303
|
* @param signal - cancels initialization; the caller closes an armed watcher.
|
|
@@ -1302,6 +1317,7 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1302
1317
|
onChange: changed,
|
|
1303
1318
|
onError: (error) => changed(error),
|
|
1304
1319
|
signal,
|
|
1320
|
+
maxDepth: this.config.watchMaxDepth,
|
|
1305
1321
|
});
|
|
1306
1322
|
}
|
|
1307
1323
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sandbox's probe and confinement logic, free of DSH peers.
|
|
3
|
+
*
|
|
4
|
+
* `lib/sandbox.js` binds this core to the peer's `SandboxUnavailableError` —
|
|
5
|
+
* the class identity `instanceof` consumers match — which is the ONLY thing
|
|
6
|
+
* that kept the whole policy out of unit tests: a checkout without the peers
|
|
7
|
+
* could not even import the module, so CI skipped every check of the probe
|
|
8
|
+
* caching policy and the argv composition. The core takes the error class as
|
|
9
|
+
* the one injected dependency; the tests inject a stand-in with the same
|
|
10
|
+
* contract (`code: "SANDBOX_UNAVAILABLE"`), and what CI runs is the code that
|
|
11
|
+
* ships.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-plugin-wsl/sandbox-core
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createProbeCache } from "./probe-cache.js";
|
|
17
|
+
import { toLinuxPath } from "./paths.js";
|
|
18
|
+
import { runCapture } from "./wsl.js";
|
|
19
|
+
import { bwrapArgvPrefix } from "./bwrap.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
|
|
23
|
+
* whose message is "Read-only file system". Classified case-insensitively.
|
|
24
|
+
*/
|
|
25
|
+
const DENIAL_SIGNATURES = ["read-only file system"];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runner-side diagnostics. Bubblewrap reports its own failures with a `bwrap: `
|
|
29
|
+
* prefix and exits 1; exit status alone never proves runner failure (the
|
|
30
|
+
* upstream rule is signature-only for exactly that reason), so a confined
|
|
31
|
+
* command that merely prints the signature is not misread as "never ran".
|
|
32
|
+
*/
|
|
33
|
+
const RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Enforcement is **partial**, not `full` like upstream's Linux rung.
|
|
37
|
+
*
|
|
38
|
+
* `SandboxMode` governs file effects. A Linux process under bwrap is fully
|
|
39
|
+
* governed — but a WSL distro process can execute a *Windows* binary through
|
|
40
|
+
* interop (`/mnt/c/**\*.exe`), and that Windows process runs outside the Linux
|
|
41
|
+
* sandbox entirely, under the user's ordinary Windows token: it can write
|
|
42
|
+
* anywhere the user can. `npm run probe:sandbox` demonstrates it. Claiming
|
|
43
|
+
* `full` would advertise a boundary that does not hold, so the provider reports
|
|
44
|
+
* the honest `partial` and README states where the hole is.
|
|
45
|
+
*/
|
|
46
|
+
const ENFORCEMENT = "partial";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one mitigation the profile can offer, and its honest ceiling.
|
|
50
|
+
*
|
|
51
|
+
* `maskWindowsDrive` mounts an empty tmpfs over `/mnt`: the Windows drive's
|
|
52
|
+
* files vanish from the command's view, which takes the drive's data off the
|
|
53
|
+
* table and makes the on-drive executables unreachable. It does NOT make the
|
|
54
|
+
* run `full`: binfmt interop dispatches on file CONTENT, so a command that can
|
|
55
|
+
* write the workspace (which `workspace-write` grants) can write a PE there
|
|
56
|
+
* and execute it — binfmt launches it as a Windows process outside the Linux
|
|
57
|
+
* sandbox regardless of where the file sits. The only complete closures are
|
|
58
|
+
* distro-level (`[interop] enabled=false` in `wsl.conf`, which costs every
|
|
59
|
+
* legitimate interop user their tooling) — so the provider reports `partial`
|
|
60
|
+
* either way, and the mask is a decision an operator makes to shrink the hole,
|
|
61
|
+
* recorded as the `windowsDrive: "masked"` fact the model can see.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
|
|
66
|
+
* failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
|
|
67
|
+
* where it is unit-testable without the peers this module needs.
|
|
68
|
+
*/
|
|
69
|
+
const probeVerdicts = createProbeCache();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the sandbox class, bound to one unavailable-error implementation.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} deps - the injected peer surface.
|
|
75
|
+
* @param {new (mode: string, detail: string) => Error} deps.SandboxUnavailableError
|
|
76
|
+
* the failure type `confine` throws; production binds the peer's class so
|
|
77
|
+
* `instanceof` consumers keep matching, tests bind a stand-in with the same
|
|
78
|
+
* `code`.
|
|
79
|
+
* @returns {typeof WslSandboxCore} the sandbox class (see `lib/sandbox.js` for
|
|
80
|
+
* the shipped binding).
|
|
81
|
+
*/
|
|
82
|
+
export function createSandboxCore({ SandboxUnavailableError }) {
|
|
83
|
+
return class WslSandboxCore {
|
|
84
|
+
/**
|
|
85
|
+
* @param config - the `wsl.exe` path, overridable for a non-standard install.
|
|
86
|
+
* @param config.maskWindowsDrive - shadow `/mnt` with an empty tmpfs in every
|
|
87
|
+
* profile this sandbox builds, hiding the Windows drive from confined
|
|
88
|
+
* commands (see the module doc above {@link ENFORCEMENT} for what that
|
|
89
|
+
* does and does not close).
|
|
90
|
+
*/
|
|
91
|
+
constructor(config = {}) {
|
|
92
|
+
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
93
|
+
this.maskWindowsDrive = config.maskWindowsDrive ?? false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether `bwrap` can create a profile inside this distro. The probe mirrors
|
|
98
|
+
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
99
|
+
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails the
|
|
100
|
+
* spawn and probes unusable, which is what makes the provider fail closed rather
|
|
101
|
+
* than silently run unconfined.
|
|
102
|
+
*
|
|
103
|
+
* A success is remembered for the process lifetime; a failure is not, so a
|
|
104
|
+
* `bubblewrap` installed while the app is running is believed on the next
|
|
105
|
+
* command.
|
|
106
|
+
*
|
|
107
|
+
* @param distro - the distro to probe.
|
|
108
|
+
* @param signal - optional cancellation.
|
|
109
|
+
* @returns true when a confined command can run there.
|
|
110
|
+
*/
|
|
111
|
+
async usable(distro, signal) {
|
|
112
|
+
const key = `${this.wslPath}\u0000${distro}`;
|
|
113
|
+
return probeVerdicts.run(key, async () => {
|
|
114
|
+
const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
|
|
115
|
+
await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Wrap one distro-side argv in the bwrap profile the policy asks for.
|
|
122
|
+
*
|
|
123
|
+
* The policy's `workspaceRoot` arrives in whichever coordinate system the
|
|
124
|
+
* caller speaks — a session workspace is a UNC path — and is translated into the
|
|
125
|
+
* Linux path `bwrap` must bind inside the distro.
|
|
126
|
+
*
|
|
127
|
+
* @param argv - the argv to run INSIDE the distro (shell, its flags, command).
|
|
128
|
+
* @param policy - the resolved confined policy (`mode` is never
|
|
129
|
+
* `danger-full-access`; the caller does not confine that case at all).
|
|
130
|
+
* @param options - the distro to run in and optional cancellation.
|
|
131
|
+
* @returns the wrapped argv plus the settlement-classification facts, in the
|
|
132
|
+
* seam's `ConfinedArgv` shape.
|
|
133
|
+
* @throws SandboxUnavailableError when the distro has no usable `bwrap`, so a
|
|
134
|
+
* requested confinement never degrades into an unconfined run.
|
|
135
|
+
*/
|
|
136
|
+
async confine(argv, policy, options) {
|
|
137
|
+
const { distro, signal } = options;
|
|
138
|
+
if (!(await this.usable(distro, signal))) {
|
|
139
|
+
throw new SandboxUnavailableError(
|
|
140
|
+
policy.mode,
|
|
141
|
+
`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
142
|
+
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
143
|
+
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
147
|
+
const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
|
|
148
|
+
return {
|
|
149
|
+
argv: [...prefix, ...argv],
|
|
150
|
+
enforcement: ENFORCEMENT,
|
|
151
|
+
denialSignatures: DENIAL_SIGNATURES,
|
|
152
|
+
runnerFailureRules: RUNNER_FAILURE_RULES,
|
|
153
|
+
windowsDrive: this.maskWindowsDrive ? "masked" : "visible",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
package/lib/sandbox.js
CHANGED
|
@@ -23,148 +23,28 @@
|
|
|
23
23
|
* The profile arguments, the denial dialect and the runner-failure rules mirror
|
|
24
24
|
* `@deepseek-ai/dsh-sandbox-local`'s `bwrap` rung, so a denial reads the same to
|
|
25
25
|
* the tool layer whether the command ran on a Linux host or inside a distro.
|
|
26
|
-
* The one deliberate difference is
|
|
26
|
+
* The one deliberate difference is the core's `partial` enforcement: see
|
|
27
|
+
* `lib/sandbox-core.js` for what it reports and why.
|
|
28
|
+
*
|
|
29
|
+
* The class itself lives in `lib/sandbox-core.js`, free of DSH peers — this
|
|
30
|
+
* module is the shipped binding that hands it the peer's `SandboxUnavailableError`,
|
|
31
|
+
* so `instanceof` consumers keep matching the class their composition knows.
|
|
27
32
|
*
|
|
28
33
|
* @module dsh-plugin-wsl/sandbox
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
|
|
32
|
-
import {
|
|
33
|
-
import { toLinuxPath } from "./paths.js";
|
|
34
|
-
import { runCapture } from "./wsl.js";
|
|
35
|
-
import { bwrapArgvPrefix, bwrapProfileArgs } from "./bwrap.js";
|
|
37
|
+
import { createSandboxCore } from "./sandbox-core.js";
|
|
36
38
|
|
|
37
39
|
// The profile and the argv prefix are also what `lib/agent-confined.js` builds
|
|
38
40
|
// for its residents, so both live in the peer-free `lib/bwrap.js` and are
|
|
39
41
|
// re-exported here, where the sandbox's callers have always found them.
|
|
40
|
-
export { bwrapProfileArgs };
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
|
|
44
|
-
* whose message is "Read-only file system". Classified case-insensitively.
|
|
45
|
-
*/
|
|
46
|
-
const DENIAL_SIGNATURES = ["read-only file system"];
|
|
42
|
+
export { bwrapProfileArgs } from "./bwrap.js";
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* upstream rule is signature-only for exactly that reason), so a confined
|
|
52
|
-
* command that merely prints the signature is not misread as "never ran".
|
|
45
|
+
* The confinement over WSL: the probe-then-confine class bound to the peer's
|
|
46
|
+
* failure type. See the core module for the probe policy and the profile.
|
|
53
47
|
*/
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Enforcement is **partial**, not `full` like upstream's Linux rung.
|
|
58
|
-
*
|
|
59
|
-
* `SandboxMode` governs file effects. A Linux process under bwrap is fully
|
|
60
|
-
* governed — but a WSL distro process can execute a *Windows* binary through
|
|
61
|
-
* interop (`/mnt/c/**\*.exe`), and that Windows process runs outside the Linux
|
|
62
|
-
* sandbox entirely, under the user's ordinary Windows token: it can write
|
|
63
|
-
* anywhere the user can. `npm run probe:sandbox` demonstrates it. Claiming
|
|
64
|
-
* `full` would advertise a boundary that does not hold, so the provider reports
|
|
65
|
-
* the honest `partial` and README states where the hole is.
|
|
66
|
-
*/
|
|
67
|
-
const ENFORCEMENT = "partial";
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The one mitigation the profile can offer, and its honest ceiling.
|
|
71
|
-
*
|
|
72
|
-
* `maskWindowsDrive` mounts an empty tmpfs over `/mnt`: the Windows drive's
|
|
73
|
-
* files vanish from the command's view, which takes the drive's data off the
|
|
74
|
-
* table and makes the on-drive executables unreachable. It does NOT make the
|
|
75
|
-
* run `full`: binfmt interop dispatches on file CONTENT, so a command that can
|
|
76
|
-
* write the workspace (which `workspace-write` grants) can write a PE there
|
|
77
|
-
* and execute it — binfmt launches it as a Windows process outside the Linux
|
|
78
|
-
* sandbox regardless of where the file sits. The only complete closures are
|
|
79
|
-
* distro-level (`[interop] enabled=false` in `wsl.conf`, which costs every
|
|
80
|
-
* legitimate interop user their tooling) — so the provider reports `partial`
|
|
81
|
-
* either way, and the mask is a decision an operator makes to shrink the hole,
|
|
82
|
-
* recorded as the `windowsDrive: "masked"` fact the model can see.
|
|
83
|
-
*/
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
|
|
87
|
-
* failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
|
|
88
|
-
* where it is unit-testable without the peers this module needs.
|
|
89
|
-
*/
|
|
90
|
-
const probeVerdicts = createProbeCache();
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Confine commands inside one distro with `bwrap`.
|
|
94
|
-
*
|
|
95
|
-
* The policy's `workspaceRoot` arrives in whichever coordinate system the
|
|
96
|
-
* caller speaks — a session workspace is a UNC path — and is translated into the
|
|
97
|
-
* Linux path `bwrap` must bind inside the distro.
|
|
98
|
-
*/
|
|
99
|
-
export class WslSandbox {
|
|
100
|
-
/**
|
|
101
|
-
* @param config - the `wsl.exe` path, overridable for a non-standard install.
|
|
102
|
-
* @param config.maskWindowsDrive - shadow `/mnt` with an empty tmpfs in every
|
|
103
|
-
* profile this sandbox builds, hiding the Windows drive from confined
|
|
104
|
-
* commands (see the module doc above {@link ENFORCEMENT} for what that
|
|
105
|
-
* does and does not close).
|
|
106
|
-
*/
|
|
107
|
-
constructor(config = {}) {
|
|
108
|
-
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
109
|
-
this.maskWindowsDrive = config.maskWindowsDrive ?? false;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Whether `bwrap` can create a profile inside this distro. The probe mirrors
|
|
114
|
-
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
115
|
-
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails the
|
|
116
|
-
* spawn and probes unusable, which is what makes the provider fail closed rather
|
|
117
|
-
* than silently run unconfined.
|
|
118
|
-
*
|
|
119
|
-
* A success is remembered for the process lifetime; a failure is not, so a
|
|
120
|
-
* `bubblewrap` installed while the app is running is believed on the next
|
|
121
|
-
* command.
|
|
122
|
-
*
|
|
123
|
-
* @param distro - the distro to probe.
|
|
124
|
-
* @param signal - optional cancellation.
|
|
125
|
-
* @returns true when a confined command can run there.
|
|
126
|
-
*/
|
|
127
|
-
async usable(distro, signal) {
|
|
128
|
-
const key = `${this.wslPath}\u0000${distro}`;
|
|
129
|
-
return probeVerdicts.run(key, async () => {
|
|
130
|
-
const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
|
|
131
|
-
await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
|
|
132
|
-
return true;
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Wrap one distro-side argv in the bwrap profile the policy asks for.
|
|
138
|
-
*
|
|
139
|
-
* @param argv - the argv to run INSIDE the distro (shell, its flags, command).
|
|
140
|
-
* @param policy - the resolved confined policy (`mode` is never
|
|
141
|
-
* `danger-full-access`; the caller does not confine that case at all).
|
|
142
|
-
* @param options - the distro to run in and optional cancellation.
|
|
143
|
-
* @returns the wrapped argv plus the settlement-classification facts, in the
|
|
144
|
-
* seam's `ConfinedArgv` shape.
|
|
145
|
-
* @throws SandboxUnavailableError when the distro has no usable `bwrap`, so a
|
|
146
|
-
* requested confinement never degrades into an unconfined run.
|
|
147
|
-
*/
|
|
148
|
-
async confine(argv, policy, options) {
|
|
149
|
-
const { distro, signal } = options;
|
|
150
|
-
if (!(await this.usable(distro, signal))) {
|
|
151
|
-
throw new SandboxUnavailableError(
|
|
152
|
-
policy.mode,
|
|
153
|
-
`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
154
|
-
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
155
|
-
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
159
|
-
const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
|
|
160
|
-
return {
|
|
161
|
-
argv: [...prefix, ...argv],
|
|
162
|
-
enforcement: ENFORCEMENT,
|
|
163
|
-
denialSignatures: DENIAL_SIGNATURES,
|
|
164
|
-
runnerFailureRules: RUNNER_FAILURE_RULES,
|
|
165
|
-
windowsDrive: this.maskWindowsDrive ? "masked" : "visible",
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
}
|
|
48
|
+
export const WslSandbox = createSandboxCore({ SandboxUnavailableError });
|
|
169
49
|
|
|
170
50
|
export default WslSandbox;
|
package/lib/watcher.js
CHANGED
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
* one `C` line per hit. No `inotifywait` dependency; the mtime walk is exact
|
|
15
15
|
* for interactive-paced edits, which is the only pace a session watch serves.
|
|
16
16
|
*
|
|
17
|
+
* The loop speaks three one-letter lines, the watcher protocol:
|
|
18
|
+
*
|
|
19
|
+
* - `R` — READY: the activation barrier; the host resolves the watch promise
|
|
20
|
+
* on it, never before.
|
|
21
|
+
* - `C` — CHANGED: something under the target may have changed.
|
|
22
|
+
* - `E` — ERROR: the watched directory no longer exists, and the loop has
|
|
23
|
+
* exited. Without it a vanished target would sit armed forever — `find`
|
|
24
|
+
* over a missing directory prints nothing, indistinguishable from "no
|
|
25
|
+
* changes" — so the loop says so and the host reports it.
|
|
26
|
+
*
|
|
17
27
|
* Known blind spot, accepted and documented: a file created with an mtime
|
|
18
28
|
* older than the stamp (`cp -p`, `tar -x`) is not seen until something else
|
|
19
29
|
* touches the tree.
|
|
@@ -29,29 +39,47 @@ import { createInterface } from "node:readline";
|
|
|
29
39
|
* quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
|
|
30
40
|
* Pure text, so it is unit-testable.
|
|
31
41
|
* @param {number} [intervalSeconds] - poll cadence; must be >= 1.
|
|
42
|
+
* @param {number} [maxDepth] - bound the scan's depth, 1 = the directory
|
|
43
|
+
* itself and its direct children; 0 (the default) scans the whole tree,
|
|
44
|
+
* which is the contract's semantics but a real cost over a tree with a
|
|
45
|
+
* `node_modules` in it.
|
|
32
46
|
* @returns {string} the `sh -c` script body.
|
|
33
47
|
*/
|
|
34
|
-
export function watchLoopScript(intervalSeconds = 2) {
|
|
48
|
+
export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
|
|
35
49
|
const seconds = Math.max(1, Math.floor(intervalSeconds));
|
|
50
|
+
const depth = Number.isInteger(maxDepth) && maxDepth >= 1 ? maxDepth : 0;
|
|
51
|
+
// A bounded walk puts `-maxdepth` directly after the path, where every find
|
|
52
|
+
// that supports it (GNU, BusyBox, BSD) accepts it as a global option.
|
|
53
|
+
const depthArgs = depth > 0 ? ` -maxdepth ${depth}` : "";
|
|
36
54
|
// `find` lists the directory itself first: a child's creation or deletion
|
|
37
55
|
// bumps the parent's mtime, so both edges are seen, not just modifications.
|
|
38
56
|
return [
|
|
39
57
|
'D="$1"',
|
|
40
58
|
'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
|
|
41
59
|
'touch "$S"',
|
|
60
|
+
// The stamp is the loop's private state, so every way out removes it. The
|
|
61
|
+
// EXIT trap covers plain exits; a signal alone would end the shell WITHOUT
|
|
62
|
+
// running it, so TERM/INT/HUP are trapped to a plain exit first.
|
|
63
|
+
"trap 'rm -f \"$S\"' EXIT",
|
|
64
|
+
"trap 'exit 0' TERM INT HUP",
|
|
42
65
|
// The READY line is the activation barrier: everything changed BEFORE it
|
|
43
66
|
// predates the stamp, so the host must not resolve the watch promise until
|
|
44
67
|
// it arrives — otherwise a write racing the first tick is invisible forever.
|
|
45
|
-
|
|
68
|
+
"printf 'R\\n'",
|
|
46
69
|
// Check first, sleep second: a change made right after activation is seen
|
|
47
70
|
// on the first cadence, not one cadence later.
|
|
48
|
-
|
|
49
|
-
' if [ -
|
|
71
|
+
"while :; do",
|
|
72
|
+
' if [ ! -d "$D" ]; then',
|
|
73
|
+
// Say so and die: silence here is the armed-forever, fires-never trap.
|
|
74
|
+
" printf 'E\\n'",
|
|
75
|
+
" exit 1",
|
|
76
|
+
" fi",
|
|
77
|
+
` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
|
|
50
78
|
' touch "$S"',
|
|
51
|
-
|
|
52
|
-
|
|
79
|
+
" printf 'C\\n'",
|
|
80
|
+
" fi",
|
|
53
81
|
` sleep ${seconds}`,
|
|
54
|
-
|
|
82
|
+
"done",
|
|
55
83
|
].join("\n");
|
|
56
84
|
}
|
|
57
85
|
|
|
@@ -63,26 +91,42 @@ export function watchLoopScript(intervalSeconds = 2) {
|
|
|
63
91
|
* @param {string} options.distro - the distro to watch inside.
|
|
64
92
|
* @param {string} options.linuxPath - the absolute Linux path to observe.
|
|
65
93
|
* @param {(error: Error) => void} [options.onError] - watcher-level failures
|
|
66
|
-
* (an exited loop); the seam reports these through its
|
|
94
|
+
* (an exited loop, a vanished target); the seam reports these through its
|
|
95
|
+
* own channel.
|
|
67
96
|
* @param {() => void} options.onChange - the invalidation callback.
|
|
68
97
|
* @param {AbortSignal} options.signal - cancels initialization; the CALLER
|
|
69
98
|
* invokes the returned close to stop an armed watcher.
|
|
70
99
|
* @param {number} [options.intervalSeconds] - poll cadence, default 2.
|
|
100
|
+
* @param {number} [options.maxDepth] - scan depth bound, 0 = the whole tree.
|
|
71
101
|
* @param {(wslPath: string, args: string[]) => import("node:child_process").ChildProcess} [options.spawn]
|
|
72
102
|
* injectable process factory for tests.
|
|
73
103
|
* @returns {Promise<() => Promise<void>>} resolves once observation is active,
|
|
74
104
|
* with the async close function the seam's contract asks for.
|
|
75
105
|
*/
|
|
76
|
-
export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, spawn: spawnProcess }) {
|
|
106
|
+
export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, maxDepth = 0, spawn: spawnProcess }) {
|
|
77
107
|
const doSpawn = spawnProcess ?? ((path, args) => spawn(path, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
|
|
78
108
|
return new Promise((resolve, reject) => {
|
|
79
109
|
if (signal?.aborted) {
|
|
80
110
|
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
81
111
|
return;
|
|
82
112
|
}
|
|
83
|
-
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds), "wsl-watch", linuxPath]);
|
|
113
|
+
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds, maxDepth), "wsl-watch", linuxPath]);
|
|
84
114
|
let settled = false;
|
|
85
115
|
let closed = false;
|
|
116
|
+
let reported = false;
|
|
117
|
+
// The loop itself writes nothing to stderr, but `sh` can (a syntax slip in
|
|
118
|
+
// a future edit) — and an undrained pipe stalls the child at the 64KB
|
|
119
|
+
// buffer limit, which would look exactly like a silently-dead watch. So
|
|
120
|
+
// the pipe is drained continuously and the tail kept for error context.
|
|
121
|
+
let stderrTail = "";
|
|
122
|
+
child.stderr?.setEncoding("utf8");
|
|
123
|
+
child.stderr?.on("data", (chunk) => {
|
|
124
|
+
stderrTail = `${stderrTail}${chunk}`.slice(-4096);
|
|
125
|
+
});
|
|
126
|
+
const describe = (message) => {
|
|
127
|
+
const detail = stderrTail.trim();
|
|
128
|
+
return detail.length > 0 ? `${message}: ${detail}` : message;
|
|
129
|
+
};
|
|
86
130
|
const lines = createInterface({ input: child.stdout });
|
|
87
131
|
lines.on("line", (line) => {
|
|
88
132
|
if (line === "R") {
|
|
@@ -92,6 +136,10 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
92
136
|
}
|
|
93
137
|
return;
|
|
94
138
|
}
|
|
139
|
+
if (line === "E") {
|
|
140
|
+
fail(new Error(describe(`the watched directory "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
95
143
|
if (line === "C") {
|
|
96
144
|
onChange();
|
|
97
145
|
}
|
|
@@ -109,6 +157,10 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
109
157
|
});
|
|
110
158
|
};
|
|
111
159
|
const fail = (error) => {
|
|
160
|
+
// One report per watcher lifetime: the loop's E line is followed by its
|
|
161
|
+
// own exit, and both must not reach the seam's error channel.
|
|
162
|
+
if (reported || closed) return;
|
|
163
|
+
reported = true;
|
|
112
164
|
if (settled) {
|
|
113
165
|
onError?.(error);
|
|
114
166
|
return;
|
|
@@ -120,8 +172,7 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
120
172
|
signal?.addEventListener("abort", () => fail(signal.reason instanceof Error ? signal.reason : new Error("aborted")), { once: true });
|
|
121
173
|
child.on("error", fail);
|
|
122
174
|
child.on("exit", (code, signalName) => {
|
|
123
|
-
|
|
175
|
+
fail(new Error(`distro watcher exited unexpectedly (code=${code} signal=${signalName ?? "none"})`));
|
|
124
176
|
});
|
|
125
|
-
|
|
126
177
|
});
|
|
127
178
|
}
|
package/lib/wsl.js
CHANGED
|
@@ -15,6 +15,18 @@ import { posixToUnc } from "./paths.js";
|
|
|
15
15
|
/** Default executable name; resolution is left to PATH. */
|
|
16
16
|
export const DEFAULT_WSL_PATH = "wsl.exe";
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The deadline every `wsl.exe` call runs under, when the caller names none.
|
|
20
|
+
*
|
|
21
|
+
* A resolution call that never returns is worse than a failed one: `wsl.exe`
|
|
22
|
+
* talks to the vmcompute service, and a wedged service would hang the tool
|
|
23
|
+
* call forever — no timeout, no error, a silent dead session. Every call now
|
|
24
|
+
* dies loudly instead. The bound is deliberately generous (a cold distro boot
|
|
25
|
+
* on a slow disk is tens of seconds), and `0` disables it for a caller that
|
|
26
|
+
* genuinely wants to wait forever.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_WSL_DEADLINE_MS = 60_000;
|
|
29
|
+
|
|
18
30
|
/**
|
|
19
31
|
* Run one host command and capture UTF-8 output.
|
|
20
32
|
*
|
|
@@ -24,16 +36,18 @@ export const DEFAULT_WSL_PATH = "wsl.exe";
|
|
|
24
36
|
*
|
|
25
37
|
* @param argv - executable plus arguments, spawned without a shell.
|
|
26
38
|
* @param signal - optional cancellation.
|
|
39
|
+
* @param deadlineMs - kill the call after this many milliseconds; the default
|
|
40
|
+
* is {@link DEFAULT_WSL_DEADLINE_MS}, and `0` waits forever.
|
|
27
41
|
* @returns captured stdout.
|
|
28
42
|
* @throws the execFile error, with a hint attached when the cause is the sandbox.
|
|
29
43
|
*/
|
|
30
|
-
export function runCapture(argv, signal) {
|
|
44
|
+
export function runCapture(argv, signal, deadlineMs = DEFAULT_WSL_DEADLINE_MS) {
|
|
31
45
|
const [file, ...args] = argv;
|
|
32
46
|
return new Promise((resolve, reject) => {
|
|
33
47
|
execFile(
|
|
34
48
|
file,
|
|
35
49
|
args,
|
|
36
|
-
{ env: { ...process.env, WSL_UTF8: "1" }, windowsHide: true, signal, encoding: "utf8" },
|
|
50
|
+
{ env: { ...process.env, WSL_UTF8: "1" }, windowsHide: true, signal, encoding: "utf8", timeout: deadlineMs },
|
|
37
51
|
(error, stdout, stderr) => {
|
|
38
52
|
if (error) {
|
|
39
53
|
// A denied WSL call is the likeliest failure here and its own message
|
|
@@ -41,6 +55,10 @@ export function runCapture(argv, signal) {
|
|
|
41
55
|
const detail = `${stdout ?? ""}${stderr ?? ""}`.replace(/\u0000/g, "").trim();
|
|
42
56
|
const denied = /E_ACCESSDENIED/i.test(detail) || error.code === "EACCES";
|
|
43
57
|
const missing = error.code === "ENOENT";
|
|
58
|
+
// The deadline kill arrives as a SIGTERM with no WSL diagnostic, so
|
|
59
|
+
// it must be named for what it is — not left looking like WSL's own
|
|
60
|
+
// failure. An abort is cancellation, not a deadline.
|
|
61
|
+
const stuck = error.killed === true && error.name !== "AbortError";
|
|
44
62
|
error.message = `${file} failed: ${detail || error.message}${
|
|
45
63
|
denied
|
|
46
64
|
? " (WSL is unreachable from a sandboxed process: DSH's Windows ACL sandbox runs commands under a restricted low-integrity token. Compose the non-sandboxing providers.)"
|
|
@@ -49,6 +67,10 @@ export function runCapture(argv, signal) {
|
|
|
49
67
|
missing
|
|
50
68
|
? ` (the executable "${file}" could not be started: check \`wslPath\` in this row, and that WSL is installed)`
|
|
51
69
|
: ""
|
|
70
|
+
}${
|
|
71
|
+
stuck
|
|
72
|
+
? ` (no completion within ${deadlineMs}ms — the WSL service may be wedged; retry once, then \`wsl.exe --shutdown\` and reopen)`
|
|
73
|
+
: ""
|
|
52
74
|
}`;
|
|
53
75
|
reject(error);
|
|
54
76
|
return;
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "WSL execution environment",
|
|
4
|
+
"description": "Runs a DeepSeek Harness session against a WSL distro: shell, file reads/writes and command execution happen inside the distro, the directory picker and the GUI terminal target distro paths, and commands are confined by in-distro bubblewrap."
|
|
5
|
+
}
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "WSL-aware directory picker",
|
|
4
|
+
"description": "The workspace picker for both worlds: its root level lists the Windows home directory and every installed distro, so host and WSL workspaces share one dialog. The directory it returns is what auto-preset reads to choose the environment."
|
|
5
|
+
}
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "WSL shell environment facts",
|
|
4
|
+
"description": "Contributes the WSL environment's facts (distro, real login shell, home, listening ports) to the managed DSH_* namespace injected into every model shell call — for sessions whose workspace is inside a distro."
|
|
5
|
+
}
|
|
6
|
+
}
|