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
|
@@ -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
|
+
}
|