dsh-wsl-workspace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * WSL discovery helpers (host side): enumerate installed distributions
3
+ * through `wsl.exe -l -q` and read the default distribution from the Lxss
4
+ * registry key. `wsl.exe` output is UTF-16LE on most builds, so decoding
5
+ * sniffs for NUL bytes before choosing an encoding.
6
+ * @module dsh-wsl-workspace/shared/wsl
7
+ */
8
+
9
+ import { execFile, execFileSync } from 'node:child_process'
10
+ import { promisify } from 'node:util'
11
+
12
+ const execFileAsync = promisify(execFile)
13
+
14
+ /** Executable timeout for the short discovery calls. */
15
+ const DISCOVERY_TIMEOUT_MS = 10_000
16
+
17
+ const LXSS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
18
+
19
+ /** Human text for an unknown rejection. */
20
+ function messageOf(value: unknown): string {
21
+ return value instanceof Error ? value.message : String(value)
22
+ }
23
+
24
+ /**
25
+ * Decode `wsl.exe -l -q` output. Newer builds emit UTF-8; most emit UTF-16LE
26
+ * with NUL bytes interleaved — the NUL probe picks the right one.
27
+ * @param buffer - the raw captured output.
28
+ * @returns the decoded text.
29
+ */
30
+ export function decodeWslOutput(buffer: Buffer): string {
31
+ return buffer.includes(0) ? buffer.toString('utf16le') : buffer.toString('utf8')
32
+ }
33
+
34
+ /**
35
+ * List installed WSL distributions in `wsl.exe` order.
36
+ * @param wslPath - the `wsl.exe` executable (absolute or PATH name).
37
+ * @returns distribution names, blank lines dropped.
38
+ */
39
+ export async function listDistros(wslPath = 'wsl.exe'): Promise<string[]> {
40
+ let stdout: Buffer
41
+ try {
42
+ const result = await execFileAsync(wslPath, ['-l', '-q'], { encoding: 'buffer', timeout: DISCOVERY_TIMEOUT_MS })
43
+ stdout = result.stdout as Buffer
44
+ } catch (error) {
45
+ throw new Error(`wsl-workspace: cannot list WSL distributions (${messageOf(error)}); is WSL installed?`)
46
+ }
47
+ return decodeWslOutput(stdout)
48
+ .split(/\r?\n/)
49
+ .map(line => line.trim())
50
+ .filter(line => line.length > 0)
51
+ }
52
+
53
+ /**
54
+ * Read the user's default distribution from the Lxss registry. Non-fatal:
55
+ * returns `undefined` when the value is absent or unreadable (the caller
56
+ * falls back to list order).
57
+ * @returns the default distribution name, or `undefined`.
58
+ */
59
+ export async function defaultDistro(): Promise<string | undefined> {
60
+ try {
61
+ const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {
62
+ timeout: DISCOVERY_TIMEOUT_MS,
63
+ })
64
+ const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(value.stdout)?.[1]
65
+ if (guid === undefined) return undefined
66
+ const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', 'DistributionName'], {
67
+ timeout: DISCOVERY_TIMEOUT_MS,
68
+ })
69
+ const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(name.stdout)?.[1]?.trim()
70
+ return distro === undefined || distro === '' ? undefined : distro
71
+ } catch {
72
+ return undefined
73
+ }
74
+ }
75
+
76
+ /** Module-level cache for {@link defaultDistroSync} (one registry read per process). */
77
+ let syncDefaultResolved = false
78
+ let syncDefault: string | undefined
79
+
80
+ /**
81
+ * Synchronous variant of {@link defaultDistro} for executors that must
82
+ * resolve a distribution inside a synchronous plan step. Cached after the
83
+ * first read; non-fatal (returns `undefined` when the registry is
84
+ * unreadable, letting the caller fail loud with its own message).
85
+ * @returns the default distribution name, or `undefined`.
86
+ */
87
+ export function defaultDistroSync(): string | undefined {
88
+ if (syncDefaultResolved) return syncDefault
89
+ syncDefaultResolved = true
90
+ try {
91
+ const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {
92
+ timeout: DISCOVERY_TIMEOUT_MS,
93
+ })
94
+ const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(String(value))?.[1]
95
+ if (guid === undefined) return undefined
96
+ const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', 'DistributionName'], {
97
+ timeout: DISCOVERY_TIMEOUT_MS,
98
+ })
99
+ const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(String(name))?.[1]?.trim()
100
+ syncDefault = distro === undefined || distro === '' ? undefined : distro
101
+ } catch {
102
+ syncDefault = undefined
103
+ }
104
+ return syncDefault
105
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * WSL Service Provider for the `ctx.shell` capability seam. Every command
3
+ * runs inside one WSL distribution as `wsl.exe -d <distro> [-u <user>]
4
+ * --cd <linux cwd> -e bash -lc <command>`, so the model-facing bash dialect
5
+ * matches the execution world exactly — the "like direct calls" experience
6
+ * of a WSL workspace session.
7
+ *
8
+ * The executor is a fresh implementation modeled on
9
+ * `@deepseek-ai/dsh-bash-local` (same deadline fusion, bounded collect,
10
+ * background adaptation) but does NOT register the shared `shell` settings
11
+ * namespace: the host composition already registers it through its own
12
+ * executor, and a second registration from a preset realm would collide.
13
+ * Configuration rides the preset row instead.
14
+ * @module dsh-wsl-workspace/shell
15
+ */
16
+
17
+ import { Context } from '@deepseek-ai/cordis'
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { ShellExecutor } from '@deepseek-ai/dsh-shell'
20
+ import type {
21
+ CollectedOutput,
22
+ ShellExecRequest,
23
+ ShellExecSpec,
24
+ ShellProcess,
25
+ ShellProcessRead,
26
+ ShellRunResult,
27
+ } from '@deepseek-ai/dsh-shell'
28
+ import type {
29
+ SubprocessCollect,
30
+ SubprocessHandle,
31
+ SubprocessOutputReader,
32
+ SubprocessSpawnSpec,
33
+ } from '@deepseek-ai/dsh-subprocess'
34
+ import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
35
+ import {
36
+ isWindowsPathShaped,
37
+ isValidWslUsername,
38
+ joinUnc,
39
+ parseWslUnc,
40
+ windowsToMntPath,
41
+ } from './shared/paths.ts'
42
+ import { getWorkspaceUsername } from './shared/wsl-credentials.ts'
43
+ import { defaultDistroSync } from './shared/wsl.ts'
44
+
45
+ /**
46
+ * Model-friendly environment overrides (same set `dsh-bash-local` hardcodes):
47
+ * disable colors, pagers, and interactive terminal features that would garble
48
+ * tool output. These values cross into the Linux process through WSLENV.
49
+ */
50
+ const ENV_OVERRIDES = {
51
+ NO_COLOR: '1',
52
+ TERM: 'dumb',
53
+ PAGER: 'cat',
54
+ GIT_PAGER: 'cat',
55
+ } as const
56
+
57
+ /** Default SIGTERM→SIGKILL grace period (matches `dsh-bash-local`). */
58
+ const DEFAULT_GRACE_MS = 3_000
59
+
60
+ /** Default per-stream spill cap (matches `dsh-bash-local`). */
61
+ const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
62
+
63
+ /** Plugin config (all optional — `static Config` supplies the defaults). */
64
+ export interface Config {
65
+ /** Default working directory (a WSL UNC or Linux path); per-call workdir wins. */
66
+ cwd?: string
67
+ /**
68
+ * Default distribution used only when a call's workdir carries no distro
69
+ * (UNC workdirs always do; Linux/Windows drive workdirs do not).
70
+ */
71
+ distro?: string
72
+ /**
73
+ * Linux user bash runs as when the call carries no per-workspace user
74
+ * (`wsl.exe -u <username>`); undefined/empty = the distro default user.
75
+ */
76
+ username?: string
77
+ /** The `wsl.exe` executable (absolute path or PATH name). */
78
+ wslPath?: string
79
+ /** Start bash as a login shell (`-lc`) so user profile PATHs (nvm, cargo…) load. */
80
+ loginShell?: boolean
81
+ /** Default foreground timeout in milliseconds. */
82
+ timeoutMs?: number
83
+ /** Upper bound for per-call timeout overrides. */
84
+ maxTimeoutMs?: number
85
+ /** Per-stream in-memory output cap; overflow spills to a temp file. */
86
+ maxOutputBytes?: number
87
+ /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
88
+ maxSpillBytes?: number
89
+ /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
90
+ graceMs?: number
91
+ }
92
+
93
+ /** The shape after schemastery applied the defaults. */
94
+ type ResolvedConfig = Required<Omit<Config, 'cwd' | 'distro' | 'username'>> & Pick<Config, 'cwd' | 'distro' | 'username'>
95
+
96
+ /** Project a settled collect-mode reader into the final CollectedOutput shape. */
97
+ function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
98
+ const read = reader.readFrom(0)
99
+ return {
100
+ text: read.text,
101
+ truncated: read.lossy,
102
+ ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
103
+ }
104
+ }
105
+
106
+ function assertPositiveFinite(name: string, value: number): void {
107
+ if (!Number.isFinite(value) || value <= 0) {
108
+ throw new Error(`wsl-shell: ${name} must be a positive finite number`)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Reject a resolved configuration this executor could not run with, so a
114
+ * stored value is refused where it is written instead of failing at the next
115
+ * command.
116
+ * @param config - the schema-validated configuration.
117
+ * @throws Error naming the field that cannot be used.
118
+ */
119
+ export function assertServiceableWslConfig(config: Config): void {
120
+ const resolved = config as ResolvedConfig
121
+ assertPositiveFinite('timeoutMs', resolved.timeoutMs)
122
+ assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
123
+ assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
124
+ assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
125
+ assertPositiveFinite('graceMs', resolved.graceMs)
126
+ if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
127
+ throw new Error(`wsl-shell: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
128
+ }
129
+ if (resolved.distro !== undefined && resolved.distro.trim() === '') {
130
+ throw new Error('wsl-shell: distro must be a non-empty distribution name')
131
+ }
132
+ if (resolved.username !== undefined && resolved.username !== '' && !isValidWslUsername(resolved.username)) {
133
+ throw new Error('wsl-shell: username must match the Linux username pattern [A-Za-z_][A-Za-z0-9_.-]*')
134
+ }
135
+ }
136
+
137
+ /** One translated execution plan: the Linux world coordinates plus the argv. */
138
+ interface WslPlan {
139
+ /** Distribution the command runs in. */
140
+ distro: string
141
+ /** Linux working directory handed to `wsl.exe --cd`. */
142
+ linuxCwd: string
143
+ /** A valid Windows directory for the `wsl.exe` process itself. */
144
+ windowsCwd: string
145
+ /** Environment map (ENV_OVERRIDES + caller env + dshEnv) with WSLENV set. */
146
+ env: Record<string, string>
147
+ /** Full argv to hand to `ctx.subprocess`. */
148
+ argv: readonly string[]
149
+ }
150
+
151
+ /**
152
+ * WSL bash executor over the LOCAL subprocess service: `wsl.exe` is a Windows
153
+ * executable, so the Windows-side spawn, bounded output, spill files, and
154
+ * process-group termination are the local subprocess seam's mechanics; this
155
+ * executor supplies the Linux-world argv, cwd translation, and WSLENV.
156
+ */
157
+ export class WslShellExecutor extends ShellExecutor {
158
+ static inject = ['subprocess']
159
+
160
+ static Config: z<Config> = z.object({
161
+ cwd: z.string(),
162
+ distro: z.string(),
163
+ username: z.string(),
164
+ wslPath: z.string().default('wsl.exe'),
165
+ loginShell: z.boolean().default(true),
166
+ timeoutMs: z.number().default(120_000),
167
+ maxTimeoutMs: z.number().default(600_000),
168
+ maxOutputBytes: z.number().default(64_000),
169
+ maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
170
+ graceMs: z.number().default(DEFAULT_GRACE_MS),
171
+ })
172
+
173
+ private readonly resolved: ResolvedConfig
174
+
175
+ /** Validated config (schemastery applied the defaults before construction). */
176
+ get config(): ResolvedConfig {
177
+ return this.resolved
178
+ }
179
+
180
+ constructor(ctx: Context, config: Config) {
181
+ super(ctx)
182
+ const entry = config as ResolvedConfig
183
+ assertServiceableWslConfig(entry)
184
+ this.resolved = entry
185
+ }
186
+
187
+ /**
188
+ * Resolve a request into a fully-specified spec: fill `workdir` from
189
+ * `config.cwd`, and `timeoutMs` from `config.timeoutMs`, capped at
190
+ * `config.maxTimeoutMs`. The tool layer calls this before
191
+ * {@link run}/{@link start}, so those methods receive explicit values.
192
+ */
193
+ resolve(request: ShellExecRequest): ShellExecSpec {
194
+ const timeoutMs = clampTimeout(
195
+ request.timeoutMs,
196
+ this.config.timeoutMs,
197
+ this.config.maxTimeoutMs,
198
+ 'wsl-shell: request.timeoutMs',
199
+ )
200
+ const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
201
+ assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
202
+ return {
203
+ command: request.command,
204
+ workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
205
+ timeoutMs,
206
+ stdoutMaxBytes,
207
+ ...request.signal ? { signal: request.signal } : {},
208
+ ...request.stdin !== undefined ? { stdin: request.stdin } : {},
209
+ ...request.env !== undefined ? { env: request.env } : {},
210
+ ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
211
+ sandboxPolicy: request.sandboxPolicy,
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Translate a resolved spec into the Linux execution plan. Fails loud on a
217
+ * workdir that names neither the WSL world (UNC or Linux path) nor a
218
+ * Windows drive path (reached through `/mnt/<drive>`).
219
+ * @param spec - the resolved execution spec.
220
+ * @returns the translated plan, including the complete argv.
221
+ */
222
+ private plan(spec: ShellExecSpec): WslPlan {
223
+ const workdir = spec.workdir
224
+ let distro: string
225
+ let linuxCwd: string
226
+ let windowsCwd: string
227
+ let username: string | undefined
228
+ const unc = parseWslUnc(workdir)
229
+ if (unc !== null) {
230
+ distro = unc.distro
231
+ linuxCwd = unc.linuxPath
232
+ // The `wsl.exe` process itself needs a plain Windows directory: its own
233
+ // cwd is irrelevant (`--cd` sets the Linux side), and spawning with a
234
+ // UNC cwd is a documented Node/Windows edge. SystemRoot always exists.
235
+ windowsCwd = process.env.SystemRoot ?? process.cwd()
236
+ username = this.resolveUser(spec, joinUnc(unc.distro, unc.linuxPath))
237
+ } else if (workdir.startsWith('/')) {
238
+ distro = this.resolveDistro(spec)
239
+ linuxCwd = workdir
240
+ windowsCwd = process.cwd()
241
+ username = this.resolveUser(spec, undefined)
242
+ } else {
243
+ const mnt = windowsToMntPath(workdir)
244
+ if (mnt === null) {
245
+ throw new Error(`wsl-shell: workdir "${workdir}" is not in the WSL execution world`)
246
+ }
247
+ distro = this.resolveDistro(spec)
248
+ linuxCwd = mnt
249
+ windowsCwd = workdir
250
+ username = this.resolveUser(spec, undefined)
251
+ }
252
+ const env = this.withWslEnv(spec)
253
+ const argv = [
254
+ this.config.wslPath,
255
+ '-d', distro,
256
+ ...(username !== undefined && username !== '' ? ['-u', username] : []),
257
+ '--cd', linuxCwd,
258
+ '-e', 'bash',
259
+ this.config.loginShell ? '-lc' : '-c',
260
+ spec.command,
261
+ ]
262
+ return { distro, linuxCwd, windowsCwd, env, argv }
263
+ }
264
+
265
+ /**
266
+ * Resolve the distribution for a workdir that carries none. The chain:
267
+ * the calling session's distribution (`DSH_WSL_DISTRO`, contributed by the
268
+ * host half from the session's UNC workspace cwd — the common case for a
269
+ * model passing a Linux `workdir`), then the configured `distro`, then the
270
+ * host's default distribution (cached registry read) as a last resort for
271
+ * plugin-driven calls with no session. Fails loud when every source is
272
+ * absent rather than guessing a distro the path does not belong to.
273
+ * @param spec - the resolved execution spec (its dshEnv carries the session fact).
274
+ * @returns the distribution name.
275
+ */
276
+ private resolveDistro(spec: ShellExecSpec): string {
277
+ const fromEnv = spec.dshEnv?.DSH_WSL_DISTRO
278
+ if (fromEnv !== undefined && fromEnv !== '') return fromEnv
279
+ const configured = this.config.distro
280
+ if (configured !== undefined && configured !== '') return configured
281
+ const fallback = defaultDistroSync()
282
+ if (fallback !== undefined) return fallback
283
+ throw new Error(
284
+ 'wsl-shell: Linux workdir carries no distribution; no session DSH_WSL_DISTRO, distro config, '
285
+ + 'or default distribution is available',
286
+ )
287
+ }
288
+
289
+ /**
290
+ * Resolve the Linux user bash runs as. The chain: the calling session's
291
+ * workspace user (`DSH_WSL_USER`, contributed by the host half), then the
292
+ * workspace's stored username when the workdir is a UNC path, then the
293
+ * configured `username`. Absent everywhere, the distribution's default
294
+ * user runs. Invalid values are skipped (they were validated on write;
295
+ * the guard is defense in depth).
296
+ * @param spec - the resolved execution spec (its dshEnv carries the session fact).
297
+ * @param uncKey - canonical UNC key of the workdir when it is a UNC path.
298
+ * @returns the username, or undefined for the distro default user.
299
+ */
300
+ private resolveUser(spec: ShellExecSpec, uncKey: string | undefined): string | undefined {
301
+ const candidates = [
302
+ spec.dshEnv?.DSH_WSL_USER,
303
+ uncKey === undefined ? undefined : getWorkspaceUsername(uncKey),
304
+ this.config.username,
305
+ ]
306
+ for (const candidate of candidates) {
307
+ if (candidate !== undefined && candidate !== '' && isValidWslUsername(candidate)) return candidate
308
+ }
309
+ return undefined
310
+ }
311
+
312
+ /**
313
+ * Merge the caller env layers and inject `WSLENV` so the Windows-side
314
+ * values reach the Linux process. Windows-path-shaped values get the `/p`
315
+ * translation flag (they become `/mnt/<drive>/…` inside WSL); the ambient
316
+ * `WSLENV` value is preserved and extended.
317
+ * @param spec - the resolved execution spec.
318
+ * @returns the explicit environment map for the spawn.
319
+ */
320
+ private withWslEnv(spec: ShellExecSpec): Record<string, string> {
321
+ const env: Record<string, string> = { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }
322
+ const flags: string[] = []
323
+ for (const [key, value] of Object.entries(env)) {
324
+ if (key.toUpperCase() === 'WSLENV') continue
325
+ flags.push(isWindowsPathShaped(value) ? `${key}/p` : key)
326
+ }
327
+ const ambient = process.env.WSLENV
328
+ const merged = [ambient, flags.join(':')].filter(part => part !== undefined && part !== '').join(':')
329
+ env.WSLENV = merged
330
+ return env
331
+ }
332
+
333
+ /** Map a plan onto a fully-specified subprocess spawn. */
334
+ private spawnSpec(plan: WslPlan, spec: ShellExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
335
+ const collect = (maxBytes: number): SubprocessCollect =>
336
+ ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
337
+ return {
338
+ argv: plan.argv,
339
+ cwd: plan.windowsCwd,
340
+ stdio: {
341
+ stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
342
+ stdout: collect(stdoutMaxBytes),
343
+ stderr: collect(this.config.maxOutputBytes),
344
+ },
345
+ graceMs: this.config.graceMs,
346
+ signal,
347
+ env: plan.env,
348
+ }
349
+ }
350
+
351
+ /** The collect-mode readers this executor requested (present by construction). */
352
+ private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
353
+ const { stdout, stderr } = handle.collected
354
+ /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
355
+ if (stdout === undefined || stderr === undefined) {
356
+ throw new Error('wsl-shell: subprocess implementation dropped a requested collect stream')
357
+ }
358
+ /* v8 ignore stop */
359
+ return { stdout, stderr }
360
+ }
361
+
362
+ /** Run one command in the foreground. */
363
+ async run(spec: ShellExecSpec): Promise<ShellRunResult> {
364
+ const plan = this.plan(spec)
365
+ using d = deadline(spec.signal, spec.timeoutMs, 'WSL_BASH_TIMEOUT')
366
+ const handle = this.ctx.subprocess.spawn(this.spawnSpec(plan, spec, spec.stdoutMaxBytes, d.signal))
367
+ const outcome = await handle.done
368
+ const collected = WslShellExecutor.collected(handle)
369
+ // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
370
+ const timedOut = timeoutOf(d.signal, 'WSL_BASH_TIMEOUT') !== undefined
371
+ const aborted = d.signal.aborted && !timedOut
372
+ return {
373
+ ...outcome,
374
+ timedOut,
375
+ aborted,
376
+ timeoutMs: spec.timeoutMs,
377
+ stdout: finalOutput(collected.stdout),
378
+ stderr: finalOutput(collected.stderr),
379
+ }
380
+ }
381
+
382
+ /** Start one command in the background and return its live handle. */
383
+ start(spec: ShellExecSpec): ShellProcess {
384
+ const plan = this.plan(spec)
385
+ // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
386
+ const running = this.ctx.subprocess.spawn(this.spawnSpec(plan, spec, this.config.maxOutputBytes, spec.signal))
387
+ const collected = WslShellExecutor.collected(running)
388
+
389
+ // A spawn failure produces no process output, so the subprocess service has
390
+ // nothing to buffer; the note is delivered exactly once through the read path.
391
+ let spawnFailureNote: string | undefined
392
+ const consumeSpawnFailure = (): string => {
393
+ const note = spawnFailureNote ?? ''
394
+ spawnFailureNote = undefined
395
+ return note
396
+ }
397
+
398
+ let stdoutOffset = 0
399
+ let stderrOffset = 0
400
+ const proc: ShellProcess = {
401
+ status: 'running',
402
+ exitCode: null,
403
+ signal: null,
404
+ done: running.done.then((outcome) => {
405
+ if (proc.status === 'running') {
406
+ proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
407
+ }
408
+ proc.exitCode = outcome.exitCode
409
+ proc.signal = outcome.signal
410
+ }, (error: unknown) => {
411
+ proc.status = 'killed'
412
+ spawnFailureNote = `spawn failed: ${String(error)}`
413
+ }),
414
+ readOutput: (): ShellProcessRead => {
415
+ const out = collected.stdout.readFrom(stdoutOffset)
416
+ const err = collected.stderr.readFrom(stderrOffset)
417
+ stdoutOffset = out.nextOffset
418
+ stderrOffset = err.nextOffset
419
+ const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
420
+ const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
421
+ const delta = out.text
422
+ + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
423
+ return {
424
+ delta,
425
+ lossy: out.lossy || err.lossy,
426
+ ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
427
+ ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
428
+ }
429
+ },
430
+ kill: (): boolean => {
431
+ if (proc.status !== 'running') return false
432
+ proc.status = 'killed'
433
+ running.terminate()
434
+ return true
435
+ },
436
+ }
437
+ return proc
438
+ }
439
+ }
440
+
441
+ export default WslShellExecutor