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/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;
@@ -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
+ }
package/lib/shell.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `ctx.shell` entrypoint — load as `dsh-plugin-wsl` in a profile patch.
3
+ * @module dsh-plugin-wsl/shell
4
+ */
5
+ export { WslShellExecutor as default, WslShellExecutor } from "./index.js";