dsh-plugin-wsl-env 0.1.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 +274 -0
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/cordis.patch.yml +306 -0
- package/examples/profile.cordis.patch.yml +36 -0
- package/lib/auto-preset.js +198 -0
- package/lib/fs.js +5 -0
- package/lib/index.js +871 -0
- package/lib/listing.js +112 -0
- package/lib/paths.js +265 -0
- package/lib/picker.js +350 -0
- package/lib/sandbox.js +162 -0
- package/lib/shell-env.js +74 -0
- package/lib/shell.js +5 -0
- package/lib/subprocess.js +230 -0
- package/lib/wsl.js +320 -0
- package/package.json +103 -0
package/lib/picker.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSL-aware directory picker: the `ctx.directoryPicker` backend that makes the
|
|
3
|
+
* Web GUI's "Select Workspace Directory" dialog open inside a distro.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this serves `kind: 'browse'` instead of declaring a `wsl` kind
|
|
6
|
+
*
|
|
7
|
+
* The seam's capability is a merge-extensible discriminated union, so adding a
|
|
8
|
+
* `wsl` kind looks like the natural move. It does not work, and the reason is
|
|
9
|
+
* worth stating plainly: the capability is **not** what crosses the wire.
|
|
10
|
+
* `dsh-api-workspace-controller` exposes exactly three Remote verbs and pins
|
|
11
|
+
* each to a literal kind —
|
|
12
|
+
*
|
|
13
|
+
* pick(signal) -> requireCapability('native', 'pick')
|
|
14
|
+
* list(path, signal) -> requireCapability('browse', 'list')
|
|
15
|
+
* createDirectory(...) -> requireCapability('browse', 'createDirectory')
|
|
16
|
+
*
|
|
17
|
+
* — and refuses anything else with `directory-picker/unavailable`. A backend
|
|
18
|
+
* reporting `{ kind: 'wsl', ... }` would therefore break the very verbs it
|
|
19
|
+
* needs, because `requireCapability('browse', ...)` would no longer match. The
|
|
20
|
+
* wire vocabulary lives in a shipped, versioned package inside `app.asar`, so
|
|
21
|
+
* adding a third kind means patching an asar package that the next app update
|
|
22
|
+
* overwrites.
|
|
23
|
+
*
|
|
24
|
+
* Serving `browse` is not a workaround, it is the fit: `browse`'s primitives are
|
|
25
|
+
* "list one absolute directory level" and "create one child directory", which is
|
|
26
|
+
* exactly what browsing a distro needs. The only thing the shipped browse
|
|
27
|
+
* backend lacks is *discoverability* of WSL — it opens at the Windows home
|
|
28
|
+
* directory. This backend closes that gap:
|
|
29
|
+
*
|
|
30
|
+
* - the root level lists the installed distros (`wsl.exe -l -q`), so WSL is
|
|
31
|
+
* the entry point rather than something you must know a UNC path for;
|
|
32
|
+
* - the breadcrumb "WSL" row jumps back to that distro list;
|
|
33
|
+
* - the dialog's Home affordance targets the preferred distro's Linux `$HOME`;
|
|
34
|
+
* - every other level is an ordinary directory listing, which the host
|
|
35
|
+
* performs over the distro's UNC share.
|
|
36
|
+
*
|
|
37
|
+
* Because the client surface is unchanged, this row composes with the **shipped**
|
|
38
|
+
* `@deepseek-ai/dsh-client-ui-directory-picker-browse` dialog: no client code,
|
|
39
|
+
* no wire change, no asar patch.
|
|
40
|
+
*
|
|
41
|
+
* @module dsh-plugin-wsl/picker
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { mkdir, opendir, stat } from "node:fs/promises";
|
|
45
|
+
import { homedir } from "node:os";
|
|
46
|
+
import { join, resolve } from "node:path";
|
|
47
|
+
import z from "@deepseek-ai/schemastery";
|
|
48
|
+
import { DirectoryPicker, DirectoryPickerError } from "@deepseek-ai/dsh-host-directory-picker";
|
|
49
|
+
import { UNC_PROVIDER_ROOT, distroRoot, isProviderRoot } from "./paths.js";
|
|
50
|
+
import { breadcrumbs, boundedInsert, fullyQualified } from "./listing.js";
|
|
51
|
+
import { listDistros, linuxHome } from "./wsl.js";
|
|
52
|
+
|
|
53
|
+
/** How long a resolved distro list stays fresh before it is queried again. */
|
|
54
|
+
const DEFAULT_DISTRO_CACHE_MS = 5_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Await `operation`, but reject with the signal's reason the moment it aborts.
|
|
58
|
+
* Node's filesystem reads are not retractable, so the operation itself keeps
|
|
59
|
+
* running against a handle the caller then closes; its late settlement is
|
|
60
|
+
* swallowed so an abandoned read cannot surface as an unhandled rejection.
|
|
61
|
+
*
|
|
62
|
+
* @param operation - the in-flight filesystem step.
|
|
63
|
+
* @param signal - caller lifetime; absent means plain awaiting.
|
|
64
|
+
* @returns the operation's value.
|
|
65
|
+
*/
|
|
66
|
+
export function raceAbort(operation, signal) {
|
|
67
|
+
if (signal === void 0) return operation;
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const onAbort = () => {
|
|
70
|
+
operation.catch(() => {});
|
|
71
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
|
|
72
|
+
};
|
|
73
|
+
if (signal.aborted) {
|
|
74
|
+
onAbort();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
78
|
+
operation.then(
|
|
79
|
+
(value) => {
|
|
80
|
+
signal.removeEventListener("abort", onAbort);
|
|
81
|
+
resolve(value);
|
|
82
|
+
},
|
|
83
|
+
(reason) => {
|
|
84
|
+
signal.removeEventListener("abort", onAbort);
|
|
85
|
+
reject(reason instanceof Error ? reason : new Error(String(reason)));
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Message text of an unknown thrown value. */
|
|
92
|
+
function messageOf(error) {
|
|
93
|
+
return error instanceof Error ? error.message : String(error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One listing row for a dirent, following symlinks to directories; null for
|
|
98
|
+
* non-directories and broken or cyclic links, which cannot be entered.
|
|
99
|
+
*/
|
|
100
|
+
async function directoryRow(parent, name, isDirectory, isSymbolicLink, signal) {
|
|
101
|
+
const path = join(parent, name);
|
|
102
|
+
let enterable = isDirectory;
|
|
103
|
+
if (!enterable && isSymbolicLink) {
|
|
104
|
+
try {
|
|
105
|
+
enterable = (await raceAbort(stat(path), signal)).isDirectory();
|
|
106
|
+
} catch {
|
|
107
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason));
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!enterable) return null;
|
|
112
|
+
return { name, path, hidden: name.startsWith(".") };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The `ctx.directoryPicker` WSL implementation.
|
|
117
|
+
*
|
|
118
|
+
* The capability object is built once per service lifetime because consumers may
|
|
119
|
+
* capture it across calls.
|
|
120
|
+
*/
|
|
121
|
+
export class WslDirectoryPicker extends DirectoryPicker {
|
|
122
|
+
static Config = z.object({
|
|
123
|
+
/** Path to `wsl.exe`; overridable for a non-standard install. */
|
|
124
|
+
wslPath: z.string().default("wsl.exe"),
|
|
125
|
+
/**
|
|
126
|
+
* Distro listed first and used for the Home affordance. Empty means WSL's
|
|
127
|
+
* own default distro.
|
|
128
|
+
*/
|
|
129
|
+
preferredDistro: z.string().default(""),
|
|
130
|
+
/**
|
|
131
|
+
* Also offer the Windows home directory at the root level, so a normal
|
|
132
|
+
* host folder stays reachable without typing a path. The dialog's editable
|
|
133
|
+
* path zone accepts any Windows path either way.
|
|
134
|
+
*/
|
|
135
|
+
includeHostHome: z.boolean().default(true),
|
|
136
|
+
/** Bound on the directory rows one `list` call may return; the level is flagged truncated. */
|
|
137
|
+
maxEntries: z.natural().min(1).default(1_000),
|
|
138
|
+
/** How long a resolved distro list stays fresh. */
|
|
139
|
+
distroCacheMs: z.natural().default(DEFAULT_DISTRO_CACHE_MS),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
constructor(ctx, config) {
|
|
143
|
+
super(ctx);
|
|
144
|
+
this.config = config;
|
|
145
|
+
/** Last distro listing, so navigating to the root does not spawn `wsl.exe` every time. */
|
|
146
|
+
this.distroCache = undefined;
|
|
147
|
+
/** Per-distro Linux home, resolved once (a distro's default user does not change under a running session). */
|
|
148
|
+
this.homeCache = new Map();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The browse interaction capability.
|
|
153
|
+
*
|
|
154
|
+
* Deliberately `browse` and not a `wsl` kind: see the module comment. The
|
|
155
|
+
* distro listing is exposed as {@link WslDirectoryPicker.distros} for
|
|
156
|
+
* programmatic callers instead of through the capability union, because a
|
|
157
|
+
* capability kind the wire controller does not recognise would disable the
|
|
158
|
+
* verbs above rather than add a new one.
|
|
159
|
+
*
|
|
160
|
+
* @returns the stable `browse` capability object.
|
|
161
|
+
*/
|
|
162
|
+
capability() {
|
|
163
|
+
return this.browseCapability;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
browseCapability = {
|
|
167
|
+
kind: "browse",
|
|
168
|
+
list: (path, signal) => this.list(path, signal),
|
|
169
|
+
createDirectory: (path, name) => this.createDirectory(path, name),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Installed distros, cached briefly.
|
|
174
|
+
* @param signal - optional cancellation.
|
|
175
|
+
* @returns distro names, default distro first.
|
|
176
|
+
*/
|
|
177
|
+
async distros(signal) {
|
|
178
|
+
const ttl = this.config.distroCacheMs;
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
if (this.distroCache !== undefined && now - this.distroCache.at < ttl) return this.distroCache.value;
|
|
181
|
+
const value = await listDistros({ wslPath: this.config.wslPath, signal });
|
|
182
|
+
this.distroCache = { at: now, value };
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The distro this picker leads with: the configured one when installed, else
|
|
188
|
+
* WSL's default, else the first installed.
|
|
189
|
+
* @param signal - optional cancellation.
|
|
190
|
+
* @returns a distro name, or empty when none is installed.
|
|
191
|
+
*/
|
|
192
|
+
async preferredDistro(signal) {
|
|
193
|
+
const distros = await this.distros(signal);
|
|
194
|
+
const configured = this.config.preferredDistro;
|
|
195
|
+
if (configured.length > 0 && distros.includes(configured)) return configured;
|
|
196
|
+
return distros[0] ?? "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A distro's Linux home directory as a UNC path, best effort: a distro that
|
|
201
|
+
* cannot be queried (stopped, and unable to start) falls back to its root so
|
|
202
|
+
* the dialog still opens somewhere real.
|
|
203
|
+
*
|
|
204
|
+
* @param distro - distro name.
|
|
205
|
+
* @param signal - optional cancellation.
|
|
206
|
+
* @returns the UNC home path.
|
|
207
|
+
*/
|
|
208
|
+
async homeOf(distro, signal) {
|
|
209
|
+
const cached = this.homeCache.get(distro);
|
|
210
|
+
if (cached !== undefined) return cached;
|
|
211
|
+
let home;
|
|
212
|
+
try {
|
|
213
|
+
home = await linuxHome(distro, { wslPath: this.config.wslPath, signal });
|
|
214
|
+
} catch {
|
|
215
|
+
home = distroRoot(distro);
|
|
216
|
+
}
|
|
217
|
+
this.homeCache.set(distro, home);
|
|
218
|
+
return home;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The root level: one row per installed distro, plus the Windows home when
|
|
223
|
+
* configured. `path` is the preferred distro's root rather than the share
|
|
224
|
+
* root, so adopting the level with nothing selected still yields a real
|
|
225
|
+
* directory; the breadcrumb's "WSL" row carries the share root and returns
|
|
226
|
+
* here.
|
|
227
|
+
*
|
|
228
|
+
* @param signal - optional cancellation.
|
|
229
|
+
* @returns the root listing.
|
|
230
|
+
*/
|
|
231
|
+
async rootLevel(signal) {
|
|
232
|
+
const distros = await this.distros(signal);
|
|
233
|
+
const preferred = await this.preferredDistro(signal);
|
|
234
|
+
const entries = [];
|
|
235
|
+
if (this.config.includeHostHome) {
|
|
236
|
+
const hostHome = homedir();
|
|
237
|
+
entries.push({ name: hostHome, path: hostHome, hidden: false });
|
|
238
|
+
}
|
|
239
|
+
for (const distro of distros) entries.push({ name: distro, path: distroRoot(distro), hidden: false });
|
|
240
|
+
const home = preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir();
|
|
241
|
+
return {
|
|
242
|
+
path: preferred.length > 0 ? distroRoot(preferred) : homedir(),
|
|
243
|
+
home,
|
|
244
|
+
crumbs: [{ name: "WSL", path: UNC_PROVIDER_ROOT, hidden: false }],
|
|
245
|
+
entries,
|
|
246
|
+
truncated: false,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Breadcrumb rows for a level, headed by the WSL row.
|
|
252
|
+
* @param target - the directory being listed.
|
|
253
|
+
* @returns breadcrumb rows.
|
|
254
|
+
*/
|
|
255
|
+
crumbsFor(target) {
|
|
256
|
+
return breadcrumbs(target, UNC_PROVIDER_ROOT);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* List one directory level.
|
|
261
|
+
* @param path - absolute directory; absent or the share root lists the distros.
|
|
262
|
+
* @param signal - caller lifetime.
|
|
263
|
+
* @returns the level's listing with its ancestry.
|
|
264
|
+
* @throws DirectoryPickerError with code `directory-unreadable`.
|
|
265
|
+
*/
|
|
266
|
+
async list(path, signal) {
|
|
267
|
+
if (path === void 0 || isProviderRoot(path)) return this.rootLevel(signal);
|
|
268
|
+
if (!fullyQualified(path)) {
|
|
269
|
+
throw new DirectoryPickerError("directory-unreadable", path, `cannot list "${path}": not a fully qualified path`);
|
|
270
|
+
}
|
|
271
|
+
const target = resolve(path);
|
|
272
|
+
const maxEntries = this.config.maxEntries;
|
|
273
|
+
const keep = maxEntries + 1;
|
|
274
|
+
const window = [];
|
|
275
|
+
let evicted = false;
|
|
276
|
+
let level;
|
|
277
|
+
try {
|
|
278
|
+
const opening = opendir(target);
|
|
279
|
+
level = await raceAbort(opening, signal).catch((error) => {
|
|
280
|
+
opening.then((dir) => dir.close().catch(() => {}), () => {});
|
|
281
|
+
throw error;
|
|
282
|
+
});
|
|
283
|
+
for (;;) {
|
|
284
|
+
const dirent = await raceAbort(level.read(), signal);
|
|
285
|
+
if (dirent === null) break;
|
|
286
|
+
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
|
|
287
|
+
if (boundedInsert(window, { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() }, keep)) {
|
|
288
|
+
evicted = true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
signal?.throwIfAborted();
|
|
293
|
+
throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(error)}`);
|
|
294
|
+
} finally {
|
|
295
|
+
if (level !== undefined) await level.close().catch(() => {});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const entries = [];
|
|
299
|
+
let truncated = evicted;
|
|
300
|
+
for (const candidate of window) {
|
|
301
|
+
signal?.throwIfAborted();
|
|
302
|
+
const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal);
|
|
303
|
+
if (row === null) continue;
|
|
304
|
+
if (entries.length === maxEntries) {
|
|
305
|
+
truncated = true;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
entries.push(row);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const preferred = await this.preferredDistro(signal);
|
|
312
|
+
return {
|
|
313
|
+
path: target,
|
|
314
|
+
home: preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir(),
|
|
315
|
+
crumbs: this.crumbsFor(target),
|
|
316
|
+
entries,
|
|
317
|
+
truncated,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Create one child directory inside the listed level.
|
|
323
|
+
* @param path - absolute existing parent directory.
|
|
324
|
+
* @param name - single non-blank path segment.
|
|
325
|
+
* @returns the created directory's absolute path.
|
|
326
|
+
* @throws DirectoryPickerError with code `directory-exists` or `directory-create-failed`.
|
|
327
|
+
*/
|
|
328
|
+
async createDirectory(path, name) {
|
|
329
|
+
if (!fullyQualified(path)) {
|
|
330
|
+
throw new DirectoryPickerError("directory-create-failed", path, `cannot create under "${path}": not a fully qualified parent path`);
|
|
331
|
+
}
|
|
332
|
+
const parent = resolve(path);
|
|
333
|
+
if (name.trim() === "" || name === "." || name === ".." || /[/\\]/.test(name)) {
|
|
334
|
+
throw new DirectoryPickerError("directory-create-failed", join(parent, name), `"${name}" is not a single path segment`);
|
|
335
|
+
}
|
|
336
|
+
const target = join(parent, name);
|
|
337
|
+
try {
|
|
338
|
+
await mkdir(target);
|
|
339
|
+
return target;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
|
|
342
|
+
throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
|
|
343
|
+
}
|
|
344
|
+
throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${messageOf(error)}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export default WslDirectoryPicker;
|
|
350
|
+
export { ancestryCrumbs, boundedInsert, breadcrumbs, fullyQualified } from "./listing.js";
|
package/lib/sandbox.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Linux-side confinement this plugin applies to commands that run inside a
|
|
3
|
+
* distro.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is not `ctx.sandbox`
|
|
6
|
+
*
|
|
7
|
+
* DSH's local sandbox provider selects its runner chain by `process.platform`.
|
|
8
|
+
* This harness process is Windows, so it selects the windows-acl runner — and
|
|
9
|
+
* that runner's restricted, low-integrity token **cannot reach WSL at all**
|
|
10
|
+
* (`wsl.exe` fails with `Wsl/E_ACCESSDENIED`). The confinement that governs a
|
|
11
|
+
* distro command therefore has to be *built* on the host and *executed* inside
|
|
12
|
+
* the distro, which is what this module produces: an argv whose program is
|
|
13
|
+
* `bwrap`, handed to `wsl.exe --exec` like any other distro command.
|
|
14
|
+
*
|
|
15
|
+
* Consuming the seam is deliberately not part of this: `dsh-tool-bash` reads
|
|
16
|
+
* `ctx.shell.sandboxMode` (the executor's capability fact) plus
|
|
17
|
+
* `ctx.sandboxPolicy`, and never asks `ctx.sandbox` for anything. A WSL
|
|
18
|
+
* executor can therefore own its confinement without shadowing the
|
|
19
|
+
* composition-level provider that host sessions still need.
|
|
20
|
+
*
|
|
21
|
+
* ## Fidelity to the upstream Linux rung
|
|
22
|
+
*
|
|
23
|
+
* The profile arguments, the denial dialect and the runner-failure rules mirror
|
|
24
|
+
* `@deepseek-ai/dsh-sandbox-local`'s `bwrap` rung, so a denial reads the same to
|
|
25
|
+
* the tool layer whether the command ran on a Linux host or inside a distro.
|
|
26
|
+
* The one deliberate difference is {@link ENFORCEMENT}: see its doc.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-plugin-wsl/sandbox
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
|
|
32
|
+
import { toLinuxPath } from "./paths.js";
|
|
33
|
+
import { runCapture } from "./wsl.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
|
|
37
|
+
* whose message is "Read-only file system". Classified case-insensitively.
|
|
38
|
+
*/
|
|
39
|
+
const DENIAL_SIGNATURES = ["read-only file system"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runner-side diagnostics. Bubblewrap reports its own failures with a `bwrap: `
|
|
43
|
+
* prefix and exits 1; exit status alone never proves runner failure (the
|
|
44
|
+
* upstream rule is signature-only for exactly that reason), so a confined
|
|
45
|
+
* command that merely prints the signature is not misread as "never ran".
|
|
46
|
+
*/
|
|
47
|
+
const RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Enforcement is **partial**, not `full` like upstream's Linux rung.
|
|
51
|
+
*
|
|
52
|
+
* `SandboxMode` governs file effects. A Linux process under bwrap is fully
|
|
53
|
+
* governed — but a WSL distro process can execute a *Windows* binary through
|
|
54
|
+
* interop (`/mnt/c/**\*.exe`), and that Windows process runs outside the Linux
|
|
55
|
+
* sandbox entirely, under the user's ordinary Windows token: it can write
|
|
56
|
+
* anywhere the user can. `npm run probe:sandbox` demonstrates it. Claiming
|
|
57
|
+
* `full` would advertise a boundary that does not hold, so the provider reports
|
|
58
|
+
* the honest `partial` and README states where the hole is.
|
|
59
|
+
*/
|
|
60
|
+
const ENFORCEMENT = "partial";
|
|
61
|
+
|
|
62
|
+
/** Probe verdicts, keyed by `wslPath\0distro`; the probe spawns once per pair. */
|
|
63
|
+
const probeVerdicts = new Map();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the bwrap profile arguments for one file-effect policy, exactly as
|
|
67
|
+
* upstream's `bwrapProfileArgs` does.
|
|
68
|
+
*
|
|
69
|
+
* `--ro-bind / /` makes the whole distro read-only, `--dev /dev` supplies the
|
|
70
|
+
* `/dev/null` sink a shell needs, `--unshare-pid` keeps the sandbox's process
|
|
71
|
+
* view its own, and `--proc /proc` gives it a matching `/proc`. Under
|
|
72
|
+
* `workspace-write` the workspace is bound read-write and `/tmp` becomes an
|
|
73
|
+
* ephemeral tmpfs — the temp area the mode promises, without exposing the
|
|
74
|
+
* distro's real `/tmp`.
|
|
75
|
+
*
|
|
76
|
+
* @param policy - the policy to express as bwrap mounts.
|
|
77
|
+
* @returns profile arguments, before the `--` separator and the command argv.
|
|
78
|
+
*/
|
|
79
|
+
export function bwrapProfileArgs(policy) {
|
|
80
|
+
const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
|
|
81
|
+
if (policy.mode === "workspace-write") {
|
|
82
|
+
args.push("--tmpfs", "/tmp");
|
|
83
|
+
args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
|
|
84
|
+
}
|
|
85
|
+
return args;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Confine commands inside one distro with `bwrap`.
|
|
90
|
+
*
|
|
91
|
+
* The policy's `workspaceRoot` arrives in whichever coordinate system the
|
|
92
|
+
* caller speaks — a session workspace is a UNC path — and is translated into the
|
|
93
|
+
* Linux path `bwrap` must bind inside the distro.
|
|
94
|
+
*/
|
|
95
|
+
export class WslSandbox {
|
|
96
|
+
/**
|
|
97
|
+
* @param config - the `wsl.exe` path, overridable for a non-standard install.
|
|
98
|
+
*/
|
|
99
|
+
constructor(config = {}) {
|
|
100
|
+
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
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.
|
|
110
|
+
*
|
|
111
|
+
* @param distro - the distro to probe.
|
|
112
|
+
* @param signal - optional cancellation.
|
|
113
|
+
* @returns true when a confined command can run there.
|
|
114
|
+
*/
|
|
115
|
+
async usable(distro, signal) {
|
|
116
|
+
const key = `${this.wslPath}\u0000${distro}`;
|
|
117
|
+
const cached = probeVerdicts.get(key);
|
|
118
|
+
if (cached !== undefined) return cached;
|
|
119
|
+
let verdict = false;
|
|
120
|
+
try {
|
|
121
|
+
const profile = bwrapProfileArgs({ mode: "read-only", workspaceRoot: "/" });
|
|
122
|
+
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;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wrap one distro-side argv in the bwrap profile the policy asks for.
|
|
133
|
+
*
|
|
134
|
+
* @param argv - the argv to run INSIDE the distro (shell, its flags, command).
|
|
135
|
+
* @param policy - the resolved confined policy (`mode` is never
|
|
136
|
+
* `danger-full-access`; the caller does not confine that case at all).
|
|
137
|
+
* @param options - the distro to run in and optional cancellation.
|
|
138
|
+
* @returns the wrapped argv plus the settlement-classification facts, in the
|
|
139
|
+
* seam's `ConfinedArgv` shape.
|
|
140
|
+
* @throws SandboxUnavailableError when the distro has no usable `bwrap`, so a
|
|
141
|
+
* requested confinement never degrades into an unconfined run.
|
|
142
|
+
*/
|
|
143
|
+
async confine(argv, policy, options) {
|
|
144
|
+
const { distro, signal } = options;
|
|
145
|
+
if (!(await this.usable(distro, signal))) {
|
|
146
|
+
throw new SandboxUnavailableError(
|
|
147
|
+
policy.mode,
|
|
148
|
+
`bwrap is not usable inside distro "${distro}" (install it: sudo apt install bubblewrap)`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
152
|
+
const profile = bwrapProfileArgs({ ...policy, workspaceRoot });
|
|
153
|
+
return {
|
|
154
|
+
argv: ["bwrap", ...profile, "--", ...argv],
|
|
155
|
+
enforcement: ENFORCEMENT,
|
|
156
|
+
denialSignatures: DENIAL_SIGNATURES,
|
|
157
|
+
runnerFailureRules: RUNNER_FAILURE_RULES,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default WslSandbox;
|
package/lib/shell-env.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contribute the WSL environment's facts to the managed `DSH_*` namespace.
|
|
3
|
+
*
|
|
4
|
+
* `dsh-shell-env` owns `ctx.shellEnv`, the registry that rebuilds the trusted
|
|
5
|
+
* `DSH_*` variables injected into every model shell call. That is DSH's sanctioned
|
|
6
|
+
* channel for telling a model what its shell environment actually is — the shell
|
|
7
|
+
* tool's own description points the model at it ("Managed `$DSH_*` variables
|
|
8
|
+
* expose current harness environment facts").
|
|
9
|
+
*
|
|
10
|
+
* It has one built-in key that looks like it should carry this, and does not:
|
|
11
|
+
*
|
|
12
|
+
* collect() { const values = { [DSH_HOME_ENV]: this.dshHome, [DSH_SHELL_KEY]: "1" } }
|
|
13
|
+
*
|
|
14
|
+
* `DSH_SHELL` is a marker FLAG (`=1`) meaning "a DSH-managed shell call", not the
|
|
15
|
+
* shell path, and it is in `RESERVED_BASH_ENV_KEYS`, so a contributor may not own
|
|
16
|
+
* it. The supported way to convey the real shell is a `DSH_`-prefixed key of our
|
|
17
|
+
* own, declared with a description and resolved synchronously.
|
|
18
|
+
*
|
|
19
|
+
* Scoping uses the same test as `auto-preset`: the agent's recorded workspace
|
|
20
|
+
* directory. A session whose workspace is inside a distro is exactly a session
|
|
21
|
+
* whose shell calls run in that distro — so a host session sees none of these.
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-plugin-wsl/shell-env
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import z from "@deepseek-ai/schemastery";
|
|
27
|
+
import { isWslUnc } from "./paths.js";
|
|
28
|
+
import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
|
|
29
|
+
|
|
30
|
+
export const name = "wsl-shell-env";
|
|
31
|
+
|
|
32
|
+
export const inject = ["shellEnv"];
|
|
33
|
+
|
|
34
|
+
export const Config = z.object({
|
|
35
|
+
/** Distro these facts describe; empty means this machine's default distro. */
|
|
36
|
+
distro: z.string().default(""),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export async function apply(ctx, config = {}) {
|
|
40
|
+
// `resolve` is called synchronously by the registry (`Object.entries` of its
|
|
41
|
+
// result), so every fact has to be resolved up front, not per call.
|
|
42
|
+
const configured = config.distro ?? "";
|
|
43
|
+
const distro = configured.length > 0 ? configured : await defaultDistro();
|
|
44
|
+
const shell = await defaultShell(distro);
|
|
45
|
+
// The consumer is a shell running INSIDE the distro, so the fact is the POSIX
|
|
46
|
+
// path, not the host-side world path.
|
|
47
|
+
const home = await linuxHomePath(distro);
|
|
48
|
+
|
|
49
|
+
const variables = {
|
|
50
|
+
DSH_WSL_DISTRO: {
|
|
51
|
+
description: `Name of the WSL distro this workspace lives in (${distro}).`,
|
|
52
|
+
},
|
|
53
|
+
DSH_WSL_SHELL: {
|
|
54
|
+
description: `Absolute path of the login shell that model shell calls run in inside ${distro}. Commands are executed by this shell, not by bash.`,
|
|
55
|
+
},
|
|
56
|
+
DSH_WSL_HOME: {
|
|
57
|
+
description: `Home directory of the user model shell calls run as inside ${distro}.`,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const dispose = ctx.shellEnv.register({
|
|
62
|
+
name: "dsh-plugin-wsl-env",
|
|
63
|
+
variables,
|
|
64
|
+
resolve: (execution) => {
|
|
65
|
+
const cwd = execution?.agent?.session?.header?.cwd;
|
|
66
|
+
if (!isWslUnc(cwd)) return {};
|
|
67
|
+
return { DSH_WSL_DISTRO: distro, DSH_WSL_SHELL: shell, DSH_WSL_HOME: home };
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Disposing matters: a config hot-reload re-runs apply(), and the registry
|
|
72
|
+
// rejects a second contributor claiming the same keys.
|
|
73
|
+
ctx.effect(() => dispose);
|
|
74
|
+
}
|