dsh-wsl-desktop 0.2.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/LICENSE +21 -0
- package/README.md +222 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +709 -0
- package/lib/http-admission.js +102 -0
- package/lib/index.js +1067 -0
- package/lib/wsl/confinement.js +424 -0
- package/lib/wsl/dsh-wsl-confine.sh +80 -0
- package/lib/wsl/fence.js +121 -0
- package/lib/wsl/fs.js +324 -0
- package/lib/wsl/host-refs.js +38 -0
- package/lib/wsl/paths.js +95 -0
- package/lib/wsl/preset.js +456 -0
- package/lib/wsl/pty.js +366 -0
- package/lib/wsl/shell.js +462 -0
- package/lib/wsl/subprocess.js +195 -0
- package/lib/wsl/terminal-bridge.py +278 -0
- package/lib/wsl/world.js +458 -0
- package/package.json +35 -0
package/lib/wsl/shell.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.shell` provider for a WSL distribution.
|
|
3
|
+
*
|
|
4
|
+
* Mounted inside a WSL agent preset's `isolate` realm, so the realm's
|
|
5
|
+
* `tool-bash` resolves this executor instead of the host's PowerShell one. The
|
|
6
|
+
* `wsl.exe` process itself is an ordinary Windows process, so it is started
|
|
7
|
+
* through the *inherited* `ctx.subprocess` — the realm isolates `shell` and
|
|
8
|
+
* `fs`, not `subprocess` — which keeps managed-range termination, output spill
|
|
9
|
+
* and disposal with the local provider.
|
|
10
|
+
*
|
|
11
|
+
* Confinement is applied inside the distribution rather than through the host's
|
|
12
|
+
* `ctx.sandbox`: a `wsl.exe` process has no meaningful Windows-side wrapper,
|
|
13
|
+
* because its children run on the Linux kernel side. The confined modes wrap
|
|
14
|
+
* the inner command in a mount namespace (see `./confinement.js`) and report
|
|
15
|
+
* the seam's sandbox facts on the result.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately extends `ShellExecutor` rather than `LocalBashExecutor`: the
|
|
18
|
+
* local executor installs the `shell` settings section in its constructor, and
|
|
19
|
+
* a second provider of that section for the same realm would collide.
|
|
20
|
+
* @module dsh-wsl-desktop/wsl/shell
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
|
|
24
|
+
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
|
25
|
+
import z from '@deepseek-ai/schemastery'
|
|
26
|
+
import {
|
|
27
|
+
DENIAL_SIGNATURES,
|
|
28
|
+
RUNNER_HELPER,
|
|
29
|
+
RUNNER_SUDO_UNSHARE,
|
|
30
|
+
buildConfinedCommand,
|
|
31
|
+
detectNoNewPrivs,
|
|
32
|
+
detectRunner,
|
|
33
|
+
resolveIdentity,
|
|
34
|
+
workspaceRootInLinux,
|
|
35
|
+
} from './confinement.js'
|
|
36
|
+
import { parseWslUnc, shellQuote, windowsToMntPath } from './paths.js'
|
|
37
|
+
import { planWsl, runWslShell, withWslEnvFlags } from './world.js'
|
|
38
|
+
|
|
39
|
+
/** Model-readable environment overrides applied to every command. */
|
|
40
|
+
const ENV_OVERRIDES = { NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' }
|
|
41
|
+
|
|
42
|
+
/** Default foreground timeout in milliseconds. */
|
|
43
|
+
const DEFAULT_TIMEOUT_MS = 120_000
|
|
44
|
+
|
|
45
|
+
/** Upper bound for a per-call timeout override. */
|
|
46
|
+
const DEFAULT_MAX_TIMEOUT_MS = 600_000
|
|
47
|
+
|
|
48
|
+
/** Per-stream in-memory output cap. */
|
|
49
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 64_000
|
|
50
|
+
|
|
51
|
+
/** Per-stream spill-file cap. */
|
|
52
|
+
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
/** SIGTERM→SIGKILL grace for the managed process range. */
|
|
55
|
+
const DEFAULT_GRACE_MS = 3_000
|
|
56
|
+
|
|
57
|
+
/** Plugin config: the local executor's shape plus the distribution choice. */
|
|
58
|
+
export const Config = z.object({
|
|
59
|
+
cwd: z.string(),
|
|
60
|
+
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS),
|
|
61
|
+
maxTimeoutMs: z.number().default(DEFAULT_MAX_TIMEOUT_MS),
|
|
62
|
+
maxOutputBytes: z.number().default(DEFAULT_MAX_OUTPUT_BYTES),
|
|
63
|
+
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
|
|
64
|
+
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
|
65
|
+
/** Distribution used when a working directory does not already name one. */
|
|
66
|
+
distro: z.string(),
|
|
67
|
+
/** Linux user to run as; omitted uses the distribution's default user. */
|
|
68
|
+
username: z.string(),
|
|
69
|
+
/** Path to `wsl.exe`. */
|
|
70
|
+
wslPath: z.string().default('wsl.exe'),
|
|
71
|
+
/** Run `bash -lc` instead of `bash -c`. */
|
|
72
|
+
loginShell: z.boolean().default(true),
|
|
73
|
+
/** Add a PID namespace to confined commands. */
|
|
74
|
+
isolateProcesses: z.boolean().default(true),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Translate a host spelling into the Linux dialect.
|
|
79
|
+
* @param {string} value - UNC, drive, or Linux path.
|
|
80
|
+
* @returns {string | null} the Linux path, or null when the value names no world.
|
|
81
|
+
*/
|
|
82
|
+
function toLinux(value) {
|
|
83
|
+
const unc = parseWslUnc(value)
|
|
84
|
+
if (unc !== null) return unc.linuxPath
|
|
85
|
+
if (value.startsWith('/')) return value
|
|
86
|
+
return windowsToMntPath(value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the Linux argv this executor hands to `ctx.subprocess`.
|
|
91
|
+
*
|
|
92
|
+
* The realm's `ctx.subprocess` is the WSL provider, so this stays in the
|
|
93
|
+
* distribution's terms: a `bash` invocation with a Linux working directory. The
|
|
94
|
+
* `wsl.exe` wrapper belongs to that provider, not here.
|
|
95
|
+
*
|
|
96
|
+
* A login shell may run profile scripts that reset the working directory, so
|
|
97
|
+
* the requested directory is re-asserted inside the script.
|
|
98
|
+
* @param {{ linuxCwd: string }} plan - the execution plan.
|
|
99
|
+
* @param {string} command - shell source from the caller.
|
|
100
|
+
* @param {{ loginShell?: boolean }} options - executor settings.
|
|
101
|
+
* @returns {string[]} the Linux argv.
|
|
102
|
+
*/
|
|
103
|
+
export function buildLinuxShellArgv(plan, command, options) {
|
|
104
|
+
const login = options.loginShell !== false
|
|
105
|
+
const script = login ? `cd ${shellQuote(plan.linuxCwd)} && ${command}` : command
|
|
106
|
+
return ['bash', login ? '-lc' : '-c', script]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The WSL bash executor.
|
|
111
|
+
*/
|
|
112
|
+
export class WslShellExecutor extends ShellExecutor {
|
|
113
|
+
static inject = ['subprocess', 'sandboxPolicy']
|
|
114
|
+
|
|
115
|
+
static Config = Config
|
|
116
|
+
|
|
117
|
+
/** Validated config. */
|
|
118
|
+
config
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the preset realm context.
|
|
122
|
+
* @param {object} config - resolved plugin config.
|
|
123
|
+
*/
|
|
124
|
+
constructor(ctx, config) {
|
|
125
|
+
super(ctx)
|
|
126
|
+
this.config = config
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The default mode this executor enforces — the capability fact the tool layer reads.
|
|
131
|
+
* @returns {string} the deployment default mode.
|
|
132
|
+
*/
|
|
133
|
+
get sandboxMode() {
|
|
134
|
+
return this.ctx.sandboxPolicy.defaultMode
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fill the caller's request with this executor's defaults, the plan and the policy.
|
|
139
|
+
* @param {object} request - the caller's request.
|
|
140
|
+
* @returns {object} the fully-specified spec, carrying a private `wslPlan`.
|
|
141
|
+
*/
|
|
142
|
+
resolve(request) {
|
|
143
|
+
const timeoutMs = Math.min(request.timeoutMs ?? this.config.timeoutMs, this.config.maxTimeoutMs)
|
|
144
|
+
const workdir = request.workdir ?? this.config.cwd ?? process.cwd()
|
|
145
|
+
return {
|
|
146
|
+
command: request.command,
|
|
147
|
+
workdir,
|
|
148
|
+
timeoutMs,
|
|
149
|
+
stdoutMaxBytes: request.stdoutMaxBytes ?? this.config.maxOutputBytes,
|
|
150
|
+
...request.signal ? { signal: request.signal } : {},
|
|
151
|
+
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
|
152
|
+
...request.env !== undefined ? { env: request.env } : {},
|
|
153
|
+
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
|
154
|
+
sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve(),
|
|
155
|
+
wslPlan: planWsl(workdir, this.config.distro),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Wrap the caller's command in the distribution's confinement when the policy
|
|
161
|
+
* requires it, or return it unchanged for full access.
|
|
162
|
+
* @param {object} spec - a resolved spec.
|
|
163
|
+
* @returns {Promise<{ command: string, sandbox: object }>} the command to run and its facts.
|
|
164
|
+
* @throws {SandboxUnavailableError} when a confined mode has no usable runner.
|
|
165
|
+
*/
|
|
166
|
+
async confinementFor(spec) {
|
|
167
|
+
const mode = spec.sandboxPolicy?.mode ?? this.ctx.sandboxPolicy.defaultMode
|
|
168
|
+
if (mode === 'danger-full-access') {
|
|
169
|
+
return { command: spec.command, sandbox: { mode, denied: false } }
|
|
170
|
+
}
|
|
171
|
+
const plan = spec.wslPlan ?? planWsl(spec.workdir, this.config.distro)
|
|
172
|
+
const probeOptions = { distro: plan.distro, run: runWslShell }
|
|
173
|
+
let identity
|
|
174
|
+
try {
|
|
175
|
+
identity = await resolveIdentity({
|
|
176
|
+
...probeOptions,
|
|
177
|
+
...this.config.username !== undefined ? { username: this.config.username } : {},
|
|
178
|
+
})
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// resolveIdentity throws with the probe's actual output when its
|
|
181
|
+
// sentinel parse fails — keep that evidence on the fail-closed error.
|
|
182
|
+
throw new SandboxUnavailableError(mode, `wsl-sandbox: 无法解析发行版内的用户身份(${error.message})`)
|
|
183
|
+
}
|
|
184
|
+
if (identity === null) {
|
|
185
|
+
throw new SandboxUnavailableError(mode, 'wsl-sandbox: 无法解析发行版内的用户身份')
|
|
186
|
+
}
|
|
187
|
+
const runner = await detectRunner({
|
|
188
|
+
...probeOptions,
|
|
189
|
+
...this.config.username !== undefined ? { username: this.config.username } : {},
|
|
190
|
+
})
|
|
191
|
+
if (runner !== RUNNER_HELPER && runner !== RUNNER_SUDO_UNSHARE) {
|
|
192
|
+
throw new SandboxUnavailableError(
|
|
193
|
+
mode,
|
|
194
|
+
'wsl-sandbox: 发行版内没有可用的约束运行器(需要免密 sudo、unshare 与 setpriv,或已安装 dsh-wsl-confine helper)',
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
// NO_NEW_PRIVS hardening is needed only on the direct sudo-unshare path —
|
|
198
|
+
// the helper's own drop always sets it. Without either mechanism the
|
|
199
|
+
// session user's retained sudo grant can re-fence-defeat at will.
|
|
200
|
+
let noNewPrivs = false
|
|
201
|
+
if (runner === RUNNER_SUDO_UNSHARE) {
|
|
202
|
+
noNewPrivs = await detectNoNewPrivs({
|
|
203
|
+
...probeOptions,
|
|
204
|
+
...this.config.username !== undefined ? { username: this.config.username } : {},
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
const workspaceLinuxRoot = workspaceRootInLinux(spec.sandboxPolicy?.workspaceRoot, plan.linuxCwd, toLinux)
|
|
208
|
+
if (workspaceLinuxRoot === null) {
|
|
209
|
+
throw new SandboxUnavailableError(mode, 'wsl-sandbox: 工作区根目录无法映射到发行版内')
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
command: buildConfinedCommand({
|
|
213
|
+
command: spec.command,
|
|
214
|
+
linuxCwd: plan.linuxCwd,
|
|
215
|
+
mode,
|
|
216
|
+
runner,
|
|
217
|
+
workspaceLinuxRoot: workspaceLinuxRoot ?? undefined,
|
|
218
|
+
identity,
|
|
219
|
+
noNewPrivs,
|
|
220
|
+
isolateProcesses: this.config.isolateProcesses,
|
|
221
|
+
}),
|
|
222
|
+
// `partial`, never `full`. The mount namespace governs the file-storage
|
|
223
|
+
// mounts (verified inside it, and a failure exits with the setup-failure
|
|
224
|
+
// code rather than running anyway), but it cannot govern things the
|
|
225
|
+
// seam's `full` promises: device and kernel surfaces (`/dev`, `/proc`,
|
|
226
|
+
// `/sys` stay writable, because a read-only devtmpfs breaks the process);
|
|
227
|
+
// interop — a confined command may still ask `wsl.exe` to run a Windows
|
|
228
|
+
// program that writes files; and privilege re-escalation — the session
|
|
229
|
+
// user keeps the passwordless sudo grant the confinement runner itself
|
|
230
|
+
// requires. When the distro's setpriv supports NO_NEW_PRIVS the drop
|
|
231
|
+
// sets it and sudo inside the fence fails loudly; on distros without
|
|
232
|
+
// that flag a deliberately non-compliant command can void the file
|
|
233
|
+
// fence via retained sudo (see README). Reporting `full` here was wrong.
|
|
234
|
+
sandbox: { mode, denied: false, enforcement: 'partial', noNewPrivs },
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build the subprocess spawn the executor hands to `ctx.subprocess`.
|
|
240
|
+
* @param {object} spec - a resolved spec.
|
|
241
|
+
* @param {number} stdoutMaxBytes - stdout capture cap.
|
|
242
|
+
* @param {AbortSignal | undefined} signal - cancellation for the spawn.
|
|
243
|
+
* @param {string | undefined} command - the command to run, already confined.
|
|
244
|
+
* @returns {object} the spawn spec, in the distribution's own terms.
|
|
245
|
+
*/
|
|
246
|
+
spawnSpec(spec, stdoutMaxBytes, signal, command) {
|
|
247
|
+
const plan = spec.wslPlan ?? planWsl(spec.workdir, this.config.distro)
|
|
248
|
+
const collect = (maxBytes) => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
|
249
|
+
return {
|
|
250
|
+
argv: buildLinuxShellArgv(plan, command ?? spec.command, { loginShell: this.config.loginShell }),
|
|
251
|
+
cwd: plan.linuxCwd,
|
|
252
|
+
stdio: {
|
|
253
|
+
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
|
254
|
+
stdout: collect(stdoutMaxBytes),
|
|
255
|
+
stderr: collect(this.config.maxOutputBytes),
|
|
256
|
+
},
|
|
257
|
+
graceMs: this.config.graceMs,
|
|
258
|
+
signal,
|
|
259
|
+
env: withWslEnvFlags({ ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }),
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Reader pair the executor itself requested.
|
|
265
|
+
* @param {object} handle - the live subprocess handle.
|
|
266
|
+
* @returns {{ stdout: object, stderr: object }} the collect readers.
|
|
267
|
+
*/
|
|
268
|
+
static readers(handle) {
|
|
269
|
+
const { stdout, stderr } = handle.collected
|
|
270
|
+
if (stdout === undefined || stderr === undefined) {
|
|
271
|
+
throw new Error('wsl-shell: subprocess provider dropped a requested collect stream')
|
|
272
|
+
}
|
|
273
|
+
return { stdout, stderr }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read one settled collect reader into the seam's output shape.
|
|
278
|
+
* @param {object} reader - the collect reader.
|
|
279
|
+
* @returns {{ text: string, truncated: boolean, spillPath?: string }} the collected output.
|
|
280
|
+
*/
|
|
281
|
+
static settled(reader) {
|
|
282
|
+
const read = reader.readFrom(0)
|
|
283
|
+
return {
|
|
284
|
+
text: read.text,
|
|
285
|
+
truncated: read.lossy,
|
|
286
|
+
...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Decide whether a settled result was refused by the confinement.
|
|
292
|
+
* @param {{ exitCode: number | null }} result - the settled result.
|
|
293
|
+
* @param {string} stderr - retained stderr text.
|
|
294
|
+
* @returns {boolean} true when the kernel refused a write.
|
|
295
|
+
*/
|
|
296
|
+
static wasDenied(result, stderr) {
|
|
297
|
+
return result.exitCode !== 0 && DENIAL_SIGNATURES.some((signature) => stderr.includes(signature))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Run one foreground command.
|
|
302
|
+
*
|
|
303
|
+
* 0.1.7 contract: `execute` resolves with a live ShellExecution handle —
|
|
304
|
+
* `{ status, exitCode, signal, done, sandbox?, readOutput(), result() }` —
|
|
305
|
+
* whose memoized `result()` projection settles with the seam's run result
|
|
306
|
+
* (first-cause timedOut/aborted, split collected streams). `run` is kept as
|
|
307
|
+
* an alias returning the same handle for pre-0.1.7 callers.
|
|
308
|
+
* @param {object} spec - a resolved spec.
|
|
309
|
+
* @returns {Promise<object>} the live execution handle.
|
|
310
|
+
*/
|
|
311
|
+
async execute(spec) {
|
|
312
|
+
const { command, sandbox } = await this.confinementFor(spec)
|
|
313
|
+
const deadline = this.deadlineFor(spec)
|
|
314
|
+
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, deadline.signal, command))
|
|
315
|
+
// Async EPIPE guard on the spawned stdin (same as the pty transport).
|
|
316
|
+
handle.stdin?.on?.('error', () => {})
|
|
317
|
+
const { stdout, stderr } = WslShellExecutor.readers(handle)
|
|
318
|
+
const state = { status: 'running', exitCode: null, signal: null }
|
|
319
|
+
const settledRef = { current: null }
|
|
320
|
+
let readOffset = 0
|
|
321
|
+
const done = handle.done.then((outcome) => {
|
|
322
|
+
const timedOut = deadline.timedOut()
|
|
323
|
+
const aborted = spec.signal?.aborted === true && !timedOut
|
|
324
|
+
const settledErr = WslShellExecutor.settled(stderr)
|
|
325
|
+
settledRef.current = {
|
|
326
|
+
...outcome,
|
|
327
|
+
timedOut,
|
|
328
|
+
aborted,
|
|
329
|
+
timeoutMs: spec.timeoutMs,
|
|
330
|
+
stdout: WslShellExecutor.settled(stdout),
|
|
331
|
+
stderr: settledErr,
|
|
332
|
+
sandbox: { ...sandbox, denied: WslShellExecutor.wasDenied(outcome, settledErr.text) },
|
|
333
|
+
}
|
|
334
|
+
state.status = aborted ? 'aborted' : timedOut ? 'timedOut' : 'completed'
|
|
335
|
+
state.exitCode = outcome.exitCode
|
|
336
|
+
state.signal = outcome.signal
|
|
337
|
+
deadline.dispose()
|
|
338
|
+
})
|
|
339
|
+
return {
|
|
340
|
+
get status() { return state.status },
|
|
341
|
+
get exitCode() { return state.exitCode },
|
|
342
|
+
get signal() { return state.signal },
|
|
343
|
+
done,
|
|
344
|
+
sandbox: { ...sandbox },
|
|
345
|
+
readOutput() {
|
|
346
|
+
const stdoutRead = stdout.readFrom(readOffset)
|
|
347
|
+
readOffset += stdoutRead.text.length
|
|
348
|
+
const stderrRead = stderr.readFrom(0)
|
|
349
|
+
return {
|
|
350
|
+
delta: stderrRead.text !== '' ? `${stdoutRead.text}\n[stderr]\n${stderrRead.text}` : stdoutRead.text,
|
|
351
|
+
lossy: stdoutRead.lossy || stderrRead.lossy,
|
|
352
|
+
...(stdoutRead.spillPath !== undefined ? { stdoutSpillPath: stdoutRead.spillPath } : {}),
|
|
353
|
+
...(stderrRead.spillPath !== undefined ? { stderrSpillPath: stderrRead.spillPath } : {}),
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
result() {
|
|
357
|
+
if (settledRef.current !== null) return Promise.resolve(settledRef.current)
|
|
358
|
+
return done.then(() => {
|
|
359
|
+
if (settledRef.current === null) {
|
|
360
|
+
throw new Error('wsl-shell: 进程已关闭但没有可用的结果投影')
|
|
361
|
+
}
|
|
362
|
+
return settledRef.current
|
|
363
|
+
})
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Pre-0.1.7 spelling of {@link WslShellExecutor.execute}; kept as an alias. */
|
|
369
|
+
run(spec) {
|
|
370
|
+
return this.execute(spec)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Start one background command.
|
|
375
|
+
* @param {object} spec - a resolved spec.
|
|
376
|
+
* @returns {Promise<object>} the live shell process handle.
|
|
377
|
+
*/
|
|
378
|
+
async start(spec) {
|
|
379
|
+
spec.signal?.throwIfAborted()
|
|
380
|
+
const { command, sandbox } = await this.confinementFor(spec)
|
|
381
|
+
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, command))
|
|
382
|
+
const { stdout, stderr } = WslShellExecutor.readers(running)
|
|
383
|
+
let stdoutOffset = 0
|
|
384
|
+
let stderrOffset = 0
|
|
385
|
+
let stderrTail
|
|
386
|
+
let observed = ''
|
|
387
|
+
const proc = {
|
|
388
|
+
status: 'running',
|
|
389
|
+
exitCode: null,
|
|
390
|
+
signal: null,
|
|
391
|
+
done: running.done.then((outcome) => {
|
|
392
|
+
if (proc.status === 'running') {
|
|
393
|
+
proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
|
|
394
|
+
}
|
|
395
|
+
proc.exitCode = outcome.exitCode
|
|
396
|
+
proc.signal = outcome.signal
|
|
397
|
+
proc.sandbox = { ...sandbox, denied: WslShellExecutor.wasDenied(outcome, observed) }
|
|
398
|
+
}, (error) => {
|
|
399
|
+
// A background provider failure settles as killed and surfaces on read.
|
|
400
|
+
proc.status = 'killed'
|
|
401
|
+
proc.exitCode = null
|
|
402
|
+
proc.signal = null
|
|
403
|
+
stderrTail = `subprocess failed before reporting an outcome: ${String(error)}`
|
|
404
|
+
}),
|
|
405
|
+
readOutput: () => {
|
|
406
|
+
const out = stdout.readFrom(stdoutOffset)
|
|
407
|
+
const err = stderr.readFrom(stderrOffset)
|
|
408
|
+
stdoutOffset = out.nextOffset
|
|
409
|
+
stderrOffset = err.nextOffset
|
|
410
|
+
const errText = err.text + (stderrTail === undefined ? '' : `\n${stderrTail}`)
|
|
411
|
+
stderrTail = undefined
|
|
412
|
+
// Retained tail for the denial classification, which runs after the last read.
|
|
413
|
+
observed = (observed + errText).slice(-4096)
|
|
414
|
+
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
|
415
|
+
return {
|
|
416
|
+
delta: out.text + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : ''),
|
|
417
|
+
lossy: out.lossy || err.lossy,
|
|
418
|
+
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
|
419
|
+
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
kill: () => {
|
|
423
|
+
if (proc.status !== 'running') return false
|
|
424
|
+
proc.status = 'killed'
|
|
425
|
+
running.terminate()
|
|
426
|
+
return true
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
return proc
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Arm this executor's timeout around the caller's own signal.
|
|
434
|
+
* @param {object} spec - a resolved spec.
|
|
435
|
+
* @returns {{ signal: AbortSignal, timedOut: () => boolean, dispose: () => void }} the deadline.
|
|
436
|
+
*/
|
|
437
|
+
deadlineFor(spec) {
|
|
438
|
+
const controller = new AbortController()
|
|
439
|
+
let expired = false
|
|
440
|
+
const timer = spec.timeoutMs > 0
|
|
441
|
+
? setTimeout(() => {
|
|
442
|
+
expired = true
|
|
443
|
+
controller.abort(new Error('WSL_BASH_TIMEOUT'))
|
|
444
|
+
}, spec.timeoutMs)
|
|
445
|
+
: undefined
|
|
446
|
+
const relay = () => controller.abort(spec.signal?.reason)
|
|
447
|
+
if (spec.signal !== undefined) {
|
|
448
|
+
if (spec.signal.aborted) relay()
|
|
449
|
+
else spec.signal.addEventListener('abort', relay, { once: true })
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
signal: controller.signal,
|
|
453
|
+
timedOut: () => expired,
|
|
454
|
+
dispose: () => {
|
|
455
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
456
|
+
spec.signal?.removeEventListener('abort', relay)
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export default WslShellExecutor
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.subprocess` provider for a WSL distribution.
|
|
3
|
+
*
|
|
4
|
+
* Mounted inside a WSL agent preset's `isolate` realm so pipe-based consumers —
|
|
5
|
+
* language servers, and anything else that speaks JSON-RPC over stdio — run
|
|
6
|
+
* inside the distribution rather than on the Windows host.
|
|
7
|
+
*
|
|
8
|
+
* The provider is deliberately thin. Starting `wsl.exe` is starting an ordinary
|
|
9
|
+
* Windows process, so every spawn is delegated to the *host's* subprocess
|
|
10
|
+
* provider (captured in `./host-refs.js`), which keeps managed-range
|
|
11
|
+
* termination, output spill, reader offsets and disposal with their owner. What
|
|
12
|
+
* this provider adds is the argv translation and the executable lookup.
|
|
13
|
+
*
|
|
14
|
+
* Two operations are refused rather than approximated:
|
|
15
|
+
* - `stdio.control` is the file-descriptor channel PTC uses; `wsl.exe` cannot
|
|
16
|
+
* forward an arbitrary descriptor, so a spawn asking for it is rejected
|
|
17
|
+
* instead of silently handing back an unconnected duplex.
|
|
18
|
+
* - `spawnTerminal` needs a PTY. `wsl.exe` pipes are not a terminal, and the
|
|
19
|
+
* repository's own subprocess provider documents terminal allocation as
|
|
20
|
+
* unsupported on win32; a caller is told so explicitly.
|
|
21
|
+
* @module dsh-wsl-desktop/wsl/subprocess
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFile } from 'node:fs/promises'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
|
28
|
+
import z from '@deepseek-ai/schemastery'
|
|
29
|
+
import { requireLocalSubprocess } from './host-refs.js'
|
|
30
|
+
import { shellQuote } from './paths.js'
|
|
31
|
+
import { spawnWslTerminal, termLog } from './pty.js'
|
|
32
|
+
import { buildWslExecArgv, planWsl, resolveLoginShell, runWslShell, withWslEnvFlags } from './world.js'
|
|
33
|
+
|
|
34
|
+
/** Executable lookup is a short probe, not a run. */
|
|
35
|
+
const LOOKUP_TIMEOUT_MS = 15_000
|
|
36
|
+
|
|
37
|
+
/** Windows shells whose names the terminal controller may probe inside the distribution. */
|
|
38
|
+
const WINDOWS_SHELLS = new Set(['powershell', 'pwsh', 'cmd'])
|
|
39
|
+
|
|
40
|
+
/** The PTY bridge shipped beside this module. */
|
|
41
|
+
const BRIDGE_PATH = join(dirname(fileURLToPath(import.meta.url)), 'terminal-bridge.py')
|
|
42
|
+
|
|
43
|
+
/** The bridge source, read once per process. */
|
|
44
|
+
let bridgeSource
|
|
45
|
+
|
|
46
|
+
/** Plugin config. */
|
|
47
|
+
export const Config = z.object({
|
|
48
|
+
/** Default distribution for working directories that do not name one. */
|
|
49
|
+
distro: z.string(),
|
|
50
|
+
/** Baseline working directory in the distribution. */
|
|
51
|
+
cwd: z.string().default('/'),
|
|
52
|
+
/** Linux user to run as; omitted uses the distribution's default user. */
|
|
53
|
+
username: z.string(),
|
|
54
|
+
/** Path to `wsl.exe`. */
|
|
55
|
+
wslPath: z.string().default('wsl.exe'),
|
|
56
|
+
/** Shell the terminal consumer should prefer inside the distribution. */
|
|
57
|
+
defaultShell: z.string().default('/bin/bash'),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The WSL subprocess provider.
|
|
62
|
+
*/
|
|
63
|
+
export class WslSubprocessRuntime extends SubprocessRuntime {
|
|
64
|
+
static Config = Config
|
|
65
|
+
|
|
66
|
+
/** Validated config. */
|
|
67
|
+
config
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the preset realm context.
|
|
71
|
+
* @param {object} config - resolved plugin config.
|
|
72
|
+
*/
|
|
73
|
+
constructor(ctx, config) {
|
|
74
|
+
super(ctx)
|
|
75
|
+
this.config = config
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve one executable inside the distribution.
|
|
80
|
+
* @param {string} command - absolute Linux path or bare `PATH` name.
|
|
81
|
+
* @param {Record<string, string>} [env] - explicit environment for the lookup.
|
|
82
|
+
* @param {AbortSignal} [signal] - lookup cancellation.
|
|
83
|
+
* @returns {Promise<string>} the canonical executable path in the distribution.
|
|
84
|
+
* @throws Error when no executable matches.
|
|
85
|
+
*/
|
|
86
|
+
async resolveExecutable(command, env, signal) {
|
|
87
|
+
const plan = planWsl(this.config.cwd ?? '/', this.config.distro)
|
|
88
|
+
const probe = command.startsWith('/')
|
|
89
|
+
? `[ -x ${shellQuote(command)} ] && printf '%s\\n' ${shellQuote(command)}`
|
|
90
|
+
: `command -v ${shellQuote(command)}`
|
|
91
|
+
const result = await runWslShell({
|
|
92
|
+
distro: plan.distro,
|
|
93
|
+
linuxCwd: plan.linuxCwd,
|
|
94
|
+
command: probe,
|
|
95
|
+
// Output-parsed probe: non-login, so profile output can never become the
|
|
96
|
+
// executable path that later gets spawned.
|
|
97
|
+
loginShell: false,
|
|
98
|
+
...this.config.username !== undefined ? { username: this.config.username } : {},
|
|
99
|
+
...env !== undefined ? { env } : {},
|
|
100
|
+
timeoutMs: LOOKUP_TIMEOUT_MS,
|
|
101
|
+
...signal !== undefined ? { signal } : {},
|
|
102
|
+
})
|
|
103
|
+
const found = result.stdout.split('\n').map((line) => line.trim()).find((line) => line.length > 0)
|
|
104
|
+
if (found === undefined) {
|
|
105
|
+
// The terminal controller resolves the deployment's configured default
|
|
106
|
+
// shell (PowerShell on a Windows deployment) through THIS provider; in
|
|
107
|
+
// the distribution that dialect-translates to the session user's login
|
|
108
|
+
// shell. Absent any Windows shell, the login shell is the honest answer.
|
|
109
|
+
const base = String(command).split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
|
|
110
|
+
if (WINDOWS_SHELLS.has(base)) {
|
|
111
|
+
return resolveLoginShell(plan.distro, this.config.username)
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`wsl-subprocess: 在发行版 ${plan.distro} 里找不到可执行文件 ${command}`)
|
|
114
|
+
}
|
|
115
|
+
return found
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Report the distribution's shell-selection facts.
|
|
120
|
+
* @returns {Promise<{ platform: 'posix', defaultShell: string }>} the terminal environment.
|
|
121
|
+
*/
|
|
122
|
+
async terminalEnvironment() {
|
|
123
|
+
return { platform: 'posix', defaultShell: this.config.defaultShell }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Start one managed process inside the distribution.
|
|
128
|
+
* @param {object} spec - argv, directory, stdio, grace, cancellation and environment.
|
|
129
|
+
* @returns {object} the host provider's live process handle.
|
|
130
|
+
* @throws Error when the spec asks for a descriptor channel the transport cannot carry.
|
|
131
|
+
*/
|
|
132
|
+
spawn(spec) {
|
|
133
|
+
if (spec.stdio?.control === 'pipe') {
|
|
134
|
+
throw new Error('wsl-subprocess: wsl.exe 无法转发控制描述符,因此不支持 stdio.control')
|
|
135
|
+
}
|
|
136
|
+
const plan = planWsl(spec.cwd, this.config.distro)
|
|
137
|
+
return requireLocalSubprocess().spawn({
|
|
138
|
+
...spec,
|
|
139
|
+
argv: buildWslExecArgv(plan, spec.argv, {
|
|
140
|
+
wslPath: this.config.wslPath,
|
|
141
|
+
...this.config.username !== undefined ? { username: this.config.username } : {},
|
|
142
|
+
}),
|
|
143
|
+
cwd: plan.windowsCwd,
|
|
144
|
+
env: withWslEnvFlags(spec.env ?? {}),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Allocate a real PTY inside the distribution and run the program on it.
|
|
150
|
+
*
|
|
151
|
+
* `wsl.exe` pipes are not a terminal, so `terminal-bridge.py` allocates one on
|
|
152
|
+
* the Linux side and this provider drives it: terminal bytes over the data
|
|
153
|
+
* process, and resize, foreground inspection, signalling and termination over
|
|
154
|
+
* a FIFO the second process holds open.
|
|
155
|
+
* @param {object} spec - argv, directory, dimensions, environment and cancellation.
|
|
156
|
+
* @returns {Promise<object>} the live terminal handle.
|
|
157
|
+
*/
|
|
158
|
+
async spawnTerminal(spec) {
|
|
159
|
+
try {
|
|
160
|
+
bridgeSource ??= await readFile(BRIDGE_PATH, 'utf8')
|
|
161
|
+
const plan = planWsl(spec.cwd, this.config.distro)
|
|
162
|
+
// The controller resolves the deployment's configured default shell
|
|
163
|
+
// (PowerShell on Windows) through THIS provider; in the distribution
|
|
164
|
+
// that dialect-translates to the session user's login shell — a lone
|
|
165
|
+
// '-NoLogo' arg would kill bash, so the whole argv is translated.
|
|
166
|
+
const base0 = String(spec.argv?.[0] ?? '').split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
|
|
167
|
+
const terminalArgv = WINDOWS_SHELLS.has(base0)
|
|
168
|
+
? [await resolveLoginShell(plan.distro, this.config.username)]
|
|
169
|
+
: spec.argv
|
|
170
|
+
termLog(`spawnTerminal: distro=${plan.distro} linuxCwd=${JSON.stringify(plan.linuxCwd)} terminalArgv=${JSON.stringify(terminalArgv)} terminalType=${String(spec.terminalType)} username=${JSON.stringify(this.config.username ?? null)}`)
|
|
171
|
+
const handle = await spawnWslTerminal({
|
|
172
|
+
local: requireLocalSubprocess(),
|
|
173
|
+
plan,
|
|
174
|
+
bridgeSource,
|
|
175
|
+
argv: terminalArgv,
|
|
176
|
+
cols: spec.cols,
|
|
177
|
+
rows: spec.rows,
|
|
178
|
+
graceMs: spec.graceMs,
|
|
179
|
+
// The consumer names the terminal type; the distribution must see it.
|
|
180
|
+
// Absent means "leave TERM alone", not "shadow it with nothing".
|
|
181
|
+
env: { ...withWslEnvFlags(spec.env ?? {}), ...(spec.terminalType !== undefined ? { TERM: spec.terminalType } : {}) },
|
|
182
|
+
...spec.signal !== undefined ? { signal: spec.signal } : {},
|
|
183
|
+
wslPath: this.config.wslPath,
|
|
184
|
+
...this.config.username !== undefined ? { username: this.config.username } : {},
|
|
185
|
+
})
|
|
186
|
+
termLog('spawnTerminal: allocated')
|
|
187
|
+
return handle
|
|
188
|
+
} catch (error) {
|
|
189
|
+
termLog(`spawnTerminal FAILED: ${error.message}`)
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default WslSubprocessRuntime
|