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.
@@ -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