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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `ctx.subprocess` entrypoint — the WSL execution world behind the GUI's
3
+ * terminal window.
4
+ *
5
+ * ## Why this seam, and not `ctx.terminals`
6
+ *
7
+ * `ctx.terminals` (`@deepseek-ai/dsh-terminal` + `dsh-terminal-bash`) is the
8
+ * *model-facing* persistent-terminal service. The right-hand terminal window is
9
+ * a different subsystem: its host half is
10
+ * `@deepseek-ai/dsh-api-terminal-controller`, mounted once by the Web bundle,
11
+ * and it resolves the execution environment of the Session it serves with
12
+ *
13
+ * execution(agent) {
14
+ * const subprocess = agent.ctx.get("subprocess");
15
+ * const sandboxPolicy = agent.ctx.get("sandboxPolicy");
16
+ * ...
17
+ * }
18
+ *
19
+ * ## Why a preset-scoped provider cannot serve it
20
+ *
21
+ * The obvious design — mount this provider in the `wsl` preset's isolate realm
22
+ * next to `shell` and `fs` — does not work, and the reason is worth recording
23
+ * because it is invisible from the profile YAML:
24
+ *
25
+ * - A preset's isolate realms are created by `dsh-agent-preset-registry`
26
+ * under the registry's **own** context:
27
+ * `createScope(this.owner, key)` → `mountPreset(scope.ctx…)`.
28
+ * - An Agent's context is a **parallel branch** created by the agent loop:
29
+ * `this.scope = createScope(loopCtx, this); this.ctx = this.scope.ctx`,
30
+ * where `loopCtx` is the root-mounted `ctx.agents` service context.
31
+ * `createScope` only wraps a Cordis plugin and tags a scope key; it neither
32
+ * touches `Context.isolate` nor passes through a loader entry, so the
33
+ * agent's isolate map still resolves every service to the root realm.
34
+ *
35
+ * So `agent.ctx.get("subprocess")` always finds the **composition-level**
36
+ * provider, whatever the agent's preset isolates. The shipped registry provides
37
+ * `agentPresets.serviceFor(agent, name)` for exactly this gap, and the terminal
38
+ * controller does not use it. (`shell` and `fs` are unaffected: the model-facing
39
+ * tools live *inside* the preset tree, so their own contexts do resolve the
40
+ * realm.)
41
+ *
42
+ * ## What this provider therefore changes
43
+ *
44
+ * It replaces the shipped `subprocess` row at the composition level and
45
+ * overrides one method: `spawnTerminal`. A `wsl.exe` launch is rewritten into a
46
+ * launch that actually names the distro and the Session workspace's Linux
47
+ * directory; every other request — including every ordinary `spawn()`, which the
48
+ * command path uses for `wsl.exe -lc <command>` — reaches the shipped
49
+ * implementation untouched.
50
+ *
51
+ * The interpreter, the PTY, output handling and process-tree teardown stay the
52
+ * shipped implementation's, so the blast radius of replacing a root service
53
+ * stays at the one thing that was wrong: the directory and distro a terminal
54
+ * starts in.
55
+ *
56
+ * ## What the operator must pair it with
57
+ *
58
+ * `terminalEnvironment()` and `resolveExecutable()` are also resolved from the
59
+ * root realm, and neither receives a Session or a directory, so this provider
60
+ * deliberately leaves them alone: rewriting them would change executable lookup
61
+ * for every other root consumer (the pwsh executor, the LSP host, the file
62
+ * search) to make the *shell menu* of one window prettier. The profile instead
63
+ * configures the terminal controller with the single shell profile that matters:
64
+ * `shell: { path: wsl.exe, name: WSL }` and `shellCandidates: []`.
65
+ *
66
+ * Consequence worth stating plainly: the terminal is a property of the
67
+ * composition, not of the Session. A Session whose workspace is a Windows folder
68
+ * gets the same terminal, with that folder mounted at `/mnt/<drive>/…`.
69
+ *
70
+ * @module dsh-plugin-wsl/subprocess
71
+ */
72
+
73
+ import z from "@deepseek-ai/schemastery";
74
+ import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
75
+ import { isPosixAbsolute, toLinuxPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
76
+ import { DEFAULT_WSL_PATH, defaultDistro, wslEnvValue, wslTerminalArgv } from "./wsl.js";
77
+
78
+ /**
79
+ * Env names a terminal launch keeps for the distro. `TERM` rides along because
80
+ * the PTY consumer sets it for the host process, and WSL imports no name that
81
+ * `WSLENV` does not list.
82
+ */
83
+ const SAFE_FORWARD = ["TERM", "NO_COLOR", "PAGER", "GIT_PAGER", "LANG", "LC_ALL"];
84
+
85
+ /** Namespace of the managed facts; every `DSH_*` name in a terminal request is forwarded. */
86
+ const DSH_ENV_PREFIX = "DSH_";
87
+
88
+ /** The one program a launch of this provider's does not pass through. */
89
+ const WSL_PROGRAM = "wsl";
90
+
91
+ /**
92
+ * Subprocess provider that serves a WSL distro as the composition's terminal
93
+ * execution world.
94
+ *
95
+ * Mount it at the profile root in place of the shipped local provider (the
96
+ * profile patch disables the `subprocess` row and inserts this one). It is a
97
+ * subclass rather than a wrapper, so every method it does not override is
98
+ * literally the shipped implementation.
99
+ */
100
+ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
101
+ static Config = z.object({
102
+ /** Distro name; empty means WSL's own default distro. */
103
+ distro: z.string().default(""),
104
+ /** Path to `wsl.exe`; overridable for a non-standard install. */
105
+ wslPath: z.string().default(DEFAULT_WSL_PATH),
106
+ /**
107
+ * A shell to pin inside the distro, as an absolute Linux path. Empty — the
108
+ * default — leaves the choice to `wsl.exe`, which starts the distro user's
109
+ * login shell exactly as an interactive `wsl.exe` does.
110
+ */
111
+ shell: z.string().default(""),
112
+ /**
113
+ * Login-shell semantics for a pinned shell. Ignored when no shell is pinned,
114
+ * because then the login shell is the shell.
115
+ */
116
+ loginShell: z.boolean().default(true),
117
+ /** Fallback directory when a launch names none; accepts any coordinate system. */
118
+ cwd: z.string().default(""),
119
+ /** Windows directory the `wsl.exe` process itself starts in. */
120
+ hostCwd: z.string().volatile(),
121
+ /** Extra env names to forward into the distro through WSLENV. */
122
+ forwardEnv: z.array(z.string()).default([]),
123
+ });
124
+
125
+ constructor(ctx, config) {
126
+ super(ctx);
127
+ this.config = config;
128
+ /** Cached default distro, resolved once from `wsl.exe -l -q`. */
129
+ this.resolvedDistro = config.distro;
130
+ }
131
+
132
+ /**
133
+ * The distro a launch uses when the request's directory does not name one,
134
+ * resolving WSL's default on first use.
135
+ * @returns the distro name.
136
+ */
137
+ async distro() {
138
+ if (this.resolvedDistro.length === 0) this.resolvedDistro = await defaultDistro({ wslPath: this.config.wslPath });
139
+ return this.resolvedDistro;
140
+ }
141
+
142
+ /** Windows directory the `wsl.exe` process is started from. */
143
+ hostCwd() {
144
+ return this.config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
145
+ }
146
+
147
+ /**
148
+ * The distro and Linux directory one terminal request names.
149
+ *
150
+ * The Session workspace the terminal controller passes in is the coordinate
151
+ * the harness already speaks, and it is also the strongest evidence of the
152
+ * intended distro: a UNC path names one outright. Anything else — a Windows
153
+ * drive path or a directory-less request — may legitimately be served by the
154
+ * configured distro.
155
+ *
156
+ * @param cwd - the request's working directory, in any coordinate system.
157
+ * @returns the distro name and its absolute Linux directory.
158
+ */
159
+ async terminalTarget(cwd) {
160
+ const text = String(cwd ?? "").trim();
161
+ const named = uncToPosix(text);
162
+ if (named !== undefined) return { distro: named.distro, linuxCwd: named.linuxPath };
163
+ const distro = await this.distro();
164
+ if (isPosixAbsolute(text)) return { distro, linuxCwd: text };
165
+ const mounted = windowsToLinuxMount(text);
166
+ if (mounted !== undefined) return { distro, linuxCwd: mounted };
167
+ return { distro, linuxCwd: toLinuxPath(this.config.cwd, { distro }) };
168
+ }
169
+
170
+ /**
171
+ * Open one interactive shell inside the distro over the host's PTY.
172
+ *
173
+ * Only a `wsl.exe` launch is rewritten. Everything else — a shipped host
174
+ * shell, the PTY backend's `bash -i`, any other consumer's terminal — is the
175
+ * shipped implementation's, which is what keeps replacing a root provider
176
+ * safe.
177
+ *
178
+ * The request's remaining arguments are intentionally not forwarded: the
179
+ * controller's shell profile carries only the program, and a second set of
180
+ * `wsl.exe` arguments would fight the distro and `--cd` this method derives
181
+ * from the Session.
182
+ *
183
+ * @param spec - the terminal request from the PTY consumer.
184
+ * @returns the live terminal handle.
185
+ */
186
+ async spawnTerminal(spec) {
187
+ if (!this.launchesDistro(spec.argv)) return super.spawnTerminal(spec);
188
+ const target = await this.terminalTarget(spec.cwd);
189
+ const argv = wslTerminalArgv({
190
+ wslPath: this.config.wslPath,
191
+ distro: target.distro,
192
+ shellPath: this.config.shell,
193
+ linuxCwd: target.linuxCwd,
194
+ login: this.config.loginShell,
195
+ });
196
+ const forward = new Set([...SAFE_FORWARD, ...this.config.forwardEnv]);
197
+ const imported = Object.keys(spec.env ?? {}).filter(
198
+ (name) => forward.has(name) || name.startsWith(DSH_ENV_PREFIX),
199
+ );
200
+ return super.spawnTerminal({
201
+ ...spec,
202
+ argv,
203
+ // A Windows CreateProcess cannot use a Linux path as its working
204
+ // directory; the Linux directory rides on `--cd` instead.
205
+ cwd: this.hostCwd(),
206
+ env: {
207
+ ...spec.env,
208
+ ...(imported.length > 0 ? { WSLENV: wslEnvValue(imported) } : {}),
209
+ },
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Whether a terminal request is a `wsl.exe` launch.
215
+ *
216
+ * A bare `argv[0]` is judged by its own name, so the shipped program name and
217
+ * an operator-pinned absolute path both match.
218
+ *
219
+ * @param argv - the request's program and arguments.
220
+ * @returns true when this provider owns the launch.
221
+ */
222
+ launchesDistro(argv) {
223
+ const program = argv?.[0];
224
+ if (typeof program !== "string" || program.length === 0) return false;
225
+ const base = program.slice(Math.max(program.lastIndexOf("/"), program.lastIndexOf("\\")) + 1);
226
+ return base.toLowerCase().replace(/\.exe$/u, "") === WSL_PROGRAM;
227
+ }
228
+ }
229
+
230
+ export default WslSubprocessRuntime;
package/lib/wsl.js ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * `wsl.exe` interop primitives shared by the shell executor, the filesystem
3
+ * backend and the directory picker.
4
+ *
5
+ * This module is deliberately separate from `index.js`: the picker runs as its
6
+ * own Loader entry and must not drag the shell executor's and filesystem
7
+ * backend's peer dependencies into that row.
8
+ *
9
+ * @module dsh-plugin-wsl/wsl
10
+ */
11
+
12
+ import { execFile } from "node:child_process";
13
+ import { posixToUnc } from "./paths.js";
14
+
15
+ /** Default executable name; resolution is left to PATH. */
16
+ export const DEFAULT_WSL_PATH = "wsl.exe";
17
+
18
+ /**
19
+ * Run one host command and capture UTF-8 output.
20
+ *
21
+ * `wsl.exe` writes UTF-16LE by default, which would arrive as interleaved NUL
22
+ * bytes on every non-ASCII distro name or path, so `WSL_UTF8=1` is set for
23
+ * every call.
24
+ *
25
+ * @param argv - executable plus arguments, spawned without a shell.
26
+ * @param signal - optional cancellation.
27
+ * @returns captured stdout.
28
+ * @throws the execFile error, with a hint attached when the cause is the sandbox.
29
+ */
30
+ export function runCapture(argv, signal) {
31
+ const [file, ...args] = argv;
32
+ return new Promise((resolve, reject) => {
33
+ execFile(
34
+ file,
35
+ args,
36
+ { env: { ...process.env, WSL_UTF8: "1" }, windowsHide: true, signal, encoding: "utf8" },
37
+ (error, stdout, stderr) => {
38
+ if (error) {
39
+ // A denied WSL call is the likeliest failure here and its own message
40
+ // is unhelpful, so name the cause when it is recognisable.
41
+ const detail = `${stdout ?? ""}${stderr ?? ""}`.replace(/\u0000/g, "").trim();
42
+ const denied = /E_ACCESSDENIED/i.test(detail) || error.code === "EACCES";
43
+ error.message = `${file} failed: ${detail || error.message}${
44
+ denied
45
+ ? " (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.)"
46
+ : ""
47
+ }`;
48
+ reject(error);
49
+ return;
50
+ }
51
+ resolve(String(stdout ?? "").replace(/\u0000/g, ""));
52
+ },
53
+ );
54
+ });
55
+ }
56
+
57
+ /**
58
+ * List installed distros with `wsl.exe -l -q`.
59
+ * @param options - the `wsl.exe` path and optional cancellation.
60
+ * @returns distro names in WSL's own order, default distro first.
61
+ */
62
+ export async function listDistros(options = {}) {
63
+ const stdout = await runCapture([options.wslPath ?? DEFAULT_WSL_PATH, "-l", "-q"], options.signal);
64
+ return stdout
65
+ .split(/\r?\n/)
66
+ .map((line) => line.trim())
67
+ .filter((line) => line.length > 0);
68
+ }
69
+
70
+ /**
71
+ * Resolve the distro to use when none is configured: WSL's own default.
72
+ * @param options - the `wsl.exe` path and optional cancellation.
73
+ * @returns the first entry of {@link listDistros}.
74
+ * @throws when no distro is installed.
75
+ */
76
+ export async function defaultDistro(options = {}) {
77
+ const [first] = await listDistros(options);
78
+ if (first === undefined) throw new Error("dsh-plugin-wsl: no WSL distro is installed (`wsl.exe -l -q` returned nothing)");
79
+ return first;
80
+ }
81
+
82
+ /**
83
+ * The Linux home directory of a distro's default user, as a Linux path. `$HOME`
84
+ * is set by `wsl.exe` itself from the distro's default user, so this needs no
85
+ * passwd parsing.
86
+ *
87
+ * @param distro - distro name.
88
+ * @param options - the `wsl.exe` path and optional cancellation.
89
+ * @returns e.g. `/home/andy`.
90
+ * @throws when the distro cannot be queried or reports no home.
91
+ */
92
+ export async function linuxHomePath(distro, options = {}) {
93
+ const stdout = await runCapture(
94
+ [options.wslPath ?? DEFAULT_WSL_PATH, "-d", distro, "--exec", "sh", "-c", 'printf %s "$HOME"'],
95
+ options.signal,
96
+ );
97
+ const home = stdout.trim();
98
+ if (home.length === 0 || !home.startsWith("/")) {
99
+ throw new Error(`dsh-plugin-wsl: distro "${distro}" reported no usable $HOME (got "${home}")`);
100
+ }
101
+ return home;
102
+ }
103
+
104
+ /**
105
+ * The same home in the world coordinate system, which is what a host-side
106
+ * consumer needs.
107
+ *
108
+ * @param distro - distro name.
109
+ * @param options - the `wsl.exe` path and optional cancellation.
110
+ * @returns e.g. `\\wsl.localhost\ubuntu\home\andy`.
111
+ * @throws when the distro cannot be queried or reports no home.
112
+ */
113
+ export async function linuxHome(distro, options = {}) {
114
+ return posixToUnc(distro, await linuxHomePath(distro, options));
115
+ }
116
+
117
+ /**
118
+ * Resolve a Linux path to its canonical form INSIDE the distro with
119
+ * `readlink -f`.
120
+ *
121
+ * This exists because the UNC share cannot traverse Linux symlinks: the share
122
+ * exposes one as a Windows reparse point whose relative POSIX target has no
123
+ * meaning to Windows path resolution, so `stat`/`realpath`/`read` on a path such
124
+ * as `/etc/os-release` (-> ../usr/lib/os-release) or `/lib` (-> usr/lib) fail
125
+ * with `ENOENT`, even though `lstat` reaches the link. Asking the distro for the
126
+ * real path is the only reliable answer, at one process per distinct path.
127
+ *
128
+ * @param distro - distro name.
129
+ * @param linuxPath - the Linux path to canonicalize.
130
+ * @param options - the `wsl.exe` path and optional cancellation.
131
+ * @returns the canonical absolute Linux path, or undefined when it cannot be
132
+ * determined (missing file, broken link, or a failed query).
133
+ */
134
+ export async function canonicalLinuxPath(distro, linuxPath, options = {}) {
135
+ try {
136
+ const stdout = await runCapture(
137
+ [options.wslPath ?? DEFAULT_WSL_PATH, "-d", distro, "--exec", "readlink", "-f", linuxPath],
138
+ options.signal,
139
+ );
140
+ const resolved = stdout.trim();
141
+ return resolved.startsWith("/") ? resolved : undefined;
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Give `targetLinux` the same POSIX mode `sourceLinux` has, inside the distro.
149
+ *
150
+ * The UNC share does not carry POSIX modes to the host: a host-side `chmod` on a
151
+ * share path is silently ignored, and a host-side `stat` reports 0666 no matter
152
+ * what the distro sees. The distro itself has the real mode, so the mode has to
153
+ * be applied there — and because a rename preserves the inode's mode, applying
154
+ * it to a staged temp file *before* publication is enough for the published file
155
+ * to come out with the right bits.
156
+ *
157
+ * `stat` + `chmod` rather than `chmod --reference` keeps this working on distros
158
+ * whose chmod is busybox's.
159
+ *
160
+ * @param distro - distro name.
161
+ * @param sourceLinux - absolute Linux path whose mode is copied.
162
+ * @param targetLinux - absolute Linux path that receives the mode.
163
+ * @param options - the `wsl.exe` path and optional cancellation.
164
+ * @throws when the mode cannot be applied.
165
+ */
166
+ export async function copyModeInDistro(distro, sourceLinux, targetLinux, options = {}) {
167
+ const script = 'if [ -e "$1" ]; then mode=$(stat -c %a "$1") && chmod "$mode" "$2"; fi';
168
+ await runCapture(
169
+ [options.wslPath ?? DEFAULT_WSL_PATH, "-d", distro, "--exec", "sh", "-c", script, "sh", sourceLinux, targetLinux],
170
+ options.signal,
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Shells known to accept the combined POSIX `-lc` login-plus-command form.
176
+ *
177
+ * `-l` is not universal: csh/tcsh signal login through argv[0] instead, and
178
+ * fish/nushell/xonsh take different flags. Anything outside this set therefore
179
+ * gets a bare `-c`, so a distro whose user runs one of those shells still works
180
+ * — it just does not get login-shell semantics.
181
+ */
182
+ const POSIX_LOGIN_SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "ksh93", "mksh", "ash", "busybox"]);
183
+
184
+ /**
185
+ * The argv flags that make `shell` run `command`, with login semantics when the
186
+ * shell supports them.
187
+ *
188
+ * @param shellPath - absolute path to the shell inside the distro.
189
+ * @param login - whether login-shell semantics were requested.
190
+ * @returns the flags to place before the command string.
191
+ */
192
+ export function shellArgs(shellPath, login) {
193
+ const name = String(shellPath).slice(String(shellPath).lastIndexOf("/") + 1).toLowerCase();
194
+ if (!POSIX_LOGIN_SHELLS.has(name)) return ["-c"];
195
+ return login ? ["-lc"] : ["-c"];
196
+ }
197
+
198
+ /**
199
+ * Shells whose `-l` flag means a login shell.
200
+ *
201
+ * Fish is included although it is absent from {@link POSIX_LOGIN_SHELLS}: fish
202
+ * rejects the combined `-lc` form that set guards, but it does accept `-l` for
203
+ * an interactive login shell. csh/tcsh signal login through argv[0] and
204
+ * nushell/xonsh use long flags, so they deliberately get nothing.
205
+ */
206
+ const LOGIN_FLAG_SHELLS = new Set([...POSIX_LOGIN_SHELLS, "fish"]);
207
+
208
+ /**
209
+ * The flags that make an interactive shell a login shell.
210
+ *
211
+ * Separate from {@link shellArgs} because an interactive terminal passes no
212
+ * command: `-lc` has no meaning there, and the login flag has to sit beside the
213
+ * caller's own `-i` (or its dialect equivalent) rather than replace it.
214
+ *
215
+ * @param shellPath - absolute path to the shell inside the distro.
216
+ * @param login - whether login-shell semantics were requested.
217
+ * @returns `["-l"]`, or an empty array for a shell whose login flag differs.
218
+ */
219
+ export function interactiveShellArgs(shellPath, login) {
220
+ const name = String(shellPath).slice(String(shellPath).lastIndexOf("/") + 1).toLowerCase();
221
+ return login && LOGIN_FLAG_SHELLS.has(name) ? ["-l"] : [];
222
+ }
223
+
224
+ /**
225
+ * The `wsl.exe` argv that opens one interactive shell inside a distro.
226
+ *
227
+ * This is the terminal twin of the command form built in `lib/index.js`, with
228
+ * three deliberate differences:
229
+ *
230
+ * - There is no command string. With no pinned shell the launch ends here and
231
+ * `wsl.exe` itself starts the distro user's login shell, which is the same
232
+ * shell an interactive `wsl.exe` gives a person — no shell has to be
233
+ * guessed, and the distro's own passwd entry decides.
234
+ * - A pinned `shellPath` is passed after `--exec`, which is as load-bearing
235
+ * here as it is for commands: without it `wsl.exe` routes the launch through
236
+ * the distro's default shell, which would expand and re-split the arguments
237
+ * before the requested interactive shell ever sees them.
238
+ * - `--cd` is omitted when no working directory is known, because `wsl.exe`
239
+ * rejects an empty `--cd` argument whereas its own default starts in the
240
+ * distro user's home.
241
+ *
242
+ * @param options - the `wsl.exe` path, distro, optional shell, its args, cwd and login mode.
243
+ * @returns the argv to spawn on the host.
244
+ */
245
+ export function wslTerminalArgv(options) {
246
+ const { wslPath = DEFAULT_WSL_PATH, distro = "", shellPath, args = [], linuxCwd, login = true } = options;
247
+ const directory = typeof linuxCwd === "string" && linuxCwd.length > 0 ? ["--cd", linuxCwd] : [];
248
+ const shell =
249
+ typeof shellPath === "string" && shellPath.length > 0
250
+ ? ["--exec", shellPath, ...interactiveShellArgs(shellPath, login), ...args]
251
+ : [];
252
+ return [wslPath, ...(distro.length > 0 ? ["-d", distro] : []), ...directory, ...shell];
253
+ }
254
+
255
+ /**
256
+ * Managed facts of the `DSH_*` namespace that carry a Windows path. WSL's
257
+ * `WSLENV` `/p` flag translates them on the way into the distro, so a command
258
+ * there sees `/mnt/c/...` instead of `C:\...`. `DSH_WSL_HOME` is deliberately
259
+ * absent: it is already a POSIX path, and translating it would corrupt it.
260
+ */
261
+ const PATH_TRANSLATED_ENV = new Set(["DSH_HOME", "DSH_PROFILE_DIR"]);
262
+
263
+ /**
264
+ * Render WSLENV's value from the env names a call wants the distro to import.
265
+ *
266
+ * `wsl.exe` imports only the names listed here; everything else in the Windows
267
+ * process environment stops at the distro boundary.
268
+ *
269
+ * @param names - env names to reveal to the distro.
270
+ * @returns the `:`-joined WSLENV value.
271
+ */
272
+ export function wslEnvValue(names) {
273
+ return names.map((name) => (PATH_TRANSLATED_ENV.has(name) ? `${name}/p` : name)).join(":");
274
+ }
275
+
276
+ /**
277
+ * Resolve the shell a distro's user actually gets, rather than assuming bash.
278
+ *
279
+ * The authoritative source is the login shell field of the user's passwd entry;
280
+ * `$SHELL` (which `wsl.exe` derives from it) is the fallback for distros without
281
+ * `getent`, and bash is the last resort. The passwd field is preferred because a
282
+ * user can export a different `SHELL` without changing their login shell.
283
+ *
284
+ * @param distro - distro name.
285
+ * @param options - the `wsl.exe` path and optional cancellation.
286
+ * @returns an absolute path to the shell inside the distro.
287
+ */
288
+ export async function defaultShell(distro, options = {}) {
289
+ const wslPath = options.wslPath ?? DEFAULT_WSL_PATH;
290
+ const ask = async (script) => {
291
+ try {
292
+ const value = (await runCapture([wslPath, "-d", distro, "--exec", "sh", "-c", script], options.signal)).trim();
293
+ return value.startsWith("/") ? value : undefined;
294
+ } catch {
295
+ return undefined;
296
+ }
297
+ };
298
+ return (
299
+ (await ask('getent passwd "$(id -u)" | cut -d: -f7')) ??
300
+ (await ask('printf %s "$SHELL"')) ??
301
+ "bash"
302
+ );
303
+ }
304
+
305
+ /**
306
+ * Run one command inside a distro and return raw stdout. Used by callers that
307
+ * want Linux semantics instead of the UNC share's.
308
+ *
309
+ * @param distro - distro name.
310
+ * @param command - a shell command string.
311
+ * @param options - the `wsl.exe` path, an explicit `shell`, and optional cancellation.
312
+ * @returns captured stdout.
313
+ */
314
+ export async function runInDistro(distro, command, options = {}) {
315
+ const shell = options.shell ?? (await defaultShell(distro, options));
316
+ return runCapture(
317
+ [options.wslPath ?? DEFAULT_WSL_PATH, "-d", distro, "--exec", shell, ...shellArgs(shell, options.loginShell !== false), command],
318
+ options.signal,
319
+ );
320
+ }
package/package.json ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "name": "dsh-plugin-wsl-env",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "cordis",
10
+ "wsl",
11
+ "windows"
12
+ ],
13
+ "type": "module",
14
+ "main": "lib/shell.js",
15
+ "exports": {
16
+ ".": "./lib/shell.js",
17
+ "./shell": "./lib/shell.js",
18
+ "./fs": "./lib/fs.js",
19
+ "./subprocess": "./lib/subprocess.js",
20
+ "./picker": "./lib/picker.js",
21
+ "./auto-preset": "./lib/auto-preset.js",
22
+ "./shell-env": "./lib/shell-env.js",
23
+ "./wsl": "./lib/wsl.js",
24
+ "./paths": "./lib/paths.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ }
31
+ },
32
+ "files": [
33
+ "lib/**/*.js",
34
+ "cordis.patch.yml",
35
+ "examples/**/*.yml",
36
+ "README.md",
37
+ "CHANGELOG.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "test": "npm run test:syntax && npm run test:unit",
42
+ "test:syntax": "node test/syntax.mjs",
43
+ "test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs",
44
+ "probe": "bash test/probe/run.sh",
45
+ "probe:terminal": "bash test/probe/terminal.sh",
46
+ "probe:sandbox": "bash test/probe/sandbox.sh",
47
+ "probe:sandbox-shell": "bash test/probe/sandbox-shell.sh",
48
+ "sync:windows": "bash test/probe/sync-to-windows.sh"
49
+ },
50
+ "engines": {
51
+ "node": ">=20"
52
+ },
53
+ "packageManager": "pnpm@11.7.0",
54
+ "license": "MIT",
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/Big-Dao/dsh-plugin-wsl-env.git"
58
+ },
59
+ "homepage": "https://github.com/Big-Dao/dsh-plugin-wsl-env#readme",
60
+ "bugs": {
61
+ "url": "https://github.com/Big-Dao/dsh-plugin-wsl-env/issues"
62
+ },
63
+ "peerDependencies": {
64
+ "@deepseek-ai/cordis": "~4.0.4",
65
+ "@deepseek-ai/dsh-bash-local": "0.2.0-rc.2",
66
+ "@deepseek-ai/dsh-fs": "0.2.0-rc.2",
67
+ "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2",
68
+ "@deepseek-ai/dsh-host-directory-picker": "0.2.0-rc.2",
69
+ "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
70
+ "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2",
71
+ "@deepseek-ai/dsh-subprocess-local": "0.2.0-rc.2",
72
+ "@deepseek-ai/schemastery": "~3.18.4"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "@deepseek-ai/cordis": {
76
+ "optional": true
77
+ },
78
+ "@deepseek-ai/dsh-bash-local": {
79
+ "optional": true
80
+ },
81
+ "@deepseek-ai/dsh-fs": {
82
+ "optional": true
83
+ },
84
+ "@deepseek-ai/dsh-fs-local": {
85
+ "optional": true
86
+ },
87
+ "@deepseek-ai/dsh-host-directory-picker": {
88
+ "optional": true
89
+ },
90
+ "@deepseek-ai/dsh-sandbox": {
91
+ "optional": true
92
+ },
93
+ "@deepseek-ai/dsh-subprocess": {
94
+ "optional": true
95
+ },
96
+ "@deepseek-ai/dsh-subprocess-local": {
97
+ "optional": true
98
+ },
99
+ "@deepseek-ai/schemastery": {
100
+ "optional": true
101
+ }
102
+ }
103
+ }