@openagents-org/agent-launcher 0.2.180 → 0.2.182

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/src/wsl.js ADDED
@@ -0,0 +1,579 @@
1
+ /**
2
+ * The Windows/WSL boundary — making agent CLIs on the other side of it both
3
+ * DETECTABLE and RUNNABLE.
4
+ *
5
+ * Two setups reported the same symptom ("my agent is installed, the launcher
6
+ * says Not installed"), and they are mirror images of each other:
7
+ *
8
+ * 1. Launcher/daemon on native Windows, the agent CLI installed inside a WSL
9
+ * distro. Every tier of the existing detection (`where`, %APPDATA%\npm,
10
+ * ~/.openagents, the known-bin-dir sweep in paths.js) looks at the Windows
11
+ * filesystem only, so a perfectly good `claude` at
12
+ * /home/<user>/.nvm/versions/node/v22.14.0/bin/claude is invisible. Hermes
13
+ * grew a one-off fallback for exactly this (adapters/hermes.js) and no
14
+ * other agent had one.
15
+ * 2. Connector running INSIDE a distro, the agent CLI installed on the
16
+ * Windows side. `which claude` misses because the file is `claude.cmd`,
17
+ * and even when found, a Windows shim cannot be exec'd from Linux.
18
+ *
19
+ * The representation stays a plain string, so detection keeps its signatures
20
+ * and only the SPAWN site needs to know the difference: an in-distro binary
21
+ * comes back prefixed, as `wsl:/home/u/.local/bin/claude`. The prefix is there
22
+ * rather than relying on "an absolute path starting with / on win32" because
23
+ * that inference is silently wrong for any caller that deals in POSIX paths on
24
+ * Windows for its own reasons — it would bridge a path nobody meant as WSL, and
25
+ * a marker that can be triggered by accident is worse than no marker.
26
+ *
27
+ * The mirror direction needs no marker: a path ending in .exe/.cmd/.bat while
28
+ * we are running inside a distro is unambiguously a Windows binary.
29
+ *
30
+ * Cost: resolution never spawns wsl.exe per binary. One probe per process
31
+ * (30s TTL) reads the distro's login PATH, and every later lookup is a
32
+ * filesystem check through the \\wsl.localhost UNC mount — which Windows serves
33
+ * without starting a distro session.
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ const fs = require('fs');
39
+ const { spawn: nodeSpawn, execFileSync } = require('child_process');
40
+
41
+ const IS_WINDOWS = process.platform === 'win32';
42
+ const PROBE_TIMEOUT_MS = 10000;
43
+ const CACHE_TTL_MS = 30 * 1000;
44
+
45
+ /** Marks a resolved path as living inside the distro: `wsl:/home/u/bin/claude`. */
46
+ const WSL_PREFIX = 'wsl:';
47
+
48
+ /** Env names that must never cross the boundary: each side has its own. */
49
+ const ENV_NEVER_FORWARD = new Set(
50
+ [
51
+ 'PATH', 'HOME', 'USER', 'USERNAME', 'USERPROFILE', 'LOGNAME', 'SHELL',
52
+ 'PWD', 'OLDPWD', 'TEMP', 'TMP', 'TMPDIR', 'APPDATA', 'LOCALAPPDATA',
53
+ 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMDATA', 'SYSTEMROOT',
54
+ 'SYSTEMDRIVE', 'WINDIR', 'COMSPEC', 'PATHEXT', 'WSLENV', 'WSL_DISTRO_NAME',
55
+ 'WSL_INTEROP', 'NODE_OPTIONS', 'NVM_DIR', 'NVM_BIN', 'VOLTA_HOME',
56
+ // getEnhancedEnv() sets LANG for the Windows console's benefit. Ubuntu
57
+ // generates C.UTF-8 and not en_US.UTF-8, so forwarding it makes every CLI
58
+ // in the distro open with a setlocale warning it had no reason to print.
59
+ 'LANG', 'LC_ALL',
60
+ ].map((n) => n.toUpperCase()),
61
+ );
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Which side of the boundary are we on?
65
+ // ---------------------------------------------------------------------------
66
+
67
+ let inWslCache;
68
+
69
+ /**
70
+ * True when THIS process is running inside a WSL distro.
71
+ *
72
+ * WSL_DISTRO_NAME is set by modern WSL but is lost by anything that sanitises
73
+ * the environment, so the kernel release string — which carries "microsoft" on
74
+ * both WSL1 and WSL2 — is the authority and the env var only a fast path.
75
+ */
76
+ function isRunningInWsl() {
77
+ if (inWslCache !== undefined) return inWslCache;
78
+ inWslCache = false;
79
+ if (process.platform !== 'linux') return inWslCache;
80
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
81
+ inWslCache = true;
82
+ return inWslCache;
83
+ }
84
+ try {
85
+ const release = fs.readFileSync('/proc/sys/kernel/osrelease', 'utf-8');
86
+ inWslCache = /microsoft|wsl/i.test(release);
87
+ } catch {
88
+ /* not Linux-with-procfs — leave false */
89
+ }
90
+ return inWslCache;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Windows side: is there a distro to talk to at all?
95
+ // ---------------------------------------------------------------------------
96
+
97
+ let distrosCache = { value: null, at: 0 };
98
+
99
+ /**
100
+ * Registered, non-broken distro names, default first.
101
+ *
102
+ * `wsl.exe -l -q` writes UTF-16LE (it is one of wsl.exe's own management
103
+ * commands, unlike `-e` which passes the child's bytes straight through), so it
104
+ * is read as a buffer and decoded explicitly — a utf-8 read yields NUL-riddled
105
+ * garbage that no name ever matches.
106
+ *
107
+ * An empty list is the normal answer on a machine where `wsl --install` was
108
+ * never finished: wsl.exe EXISTS in System32 on every Windows 11 install, so
109
+ * its presence proves nothing and only a registered distro does.
110
+ */
111
+ function wslDistros() {
112
+ if (!IS_WINDOWS) return [];
113
+ if (distrosCache.value && Date.now() - distrosCache.at < CACHE_TTL_MS) {
114
+ return [...distrosCache.value];
115
+ }
116
+ let names = [];
117
+ try {
118
+ const buf = execFileSync('wsl.exe', ['-l', '-q'], {
119
+ timeout: PROBE_TIMEOUT_MS,
120
+ windowsHide: true,
121
+ stdio: ['ignore', 'pipe', 'ignore'],
122
+ maxBuffer: 1024 * 1024,
123
+ });
124
+ names = buf
125
+ .toString('utf16le')
126
+ .split(/\r?\n/)
127
+ .map((l) => l.replace(/\0/g, '').trim())
128
+ .filter(Boolean);
129
+ } catch {
130
+ names = [];
131
+ }
132
+ distrosCache = { value: names, at: Date.now() };
133
+ return [...names];
134
+ }
135
+
136
+ /** True when a WSL distro is registered and reachable from this Windows host. */
137
+ function isWslAvailable() {
138
+ return IS_WINDOWS && wslDistros().length > 0;
139
+ }
140
+
141
+ /** The distro every bridged command targets — wsl.exe's own default. */
142
+ function defaultDistro() {
143
+ return wslDistros()[0] || null;
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Windows side: reaching into the distro's filesystem without starting one
148
+ // ---------------------------------------------------------------------------
149
+
150
+ let uncRootCache = { value: null, at: 0 };
151
+
152
+ /**
153
+ * The UNC prefix that maps the default distro's root into Windows, or null.
154
+ * WSL2 serves \\wsl.localhost\<distro>; WSL1 and older builds only \\wsl$\.
155
+ */
156
+ function _uncRoot() {
157
+ if (uncRootCache.at && Date.now() - uncRootCache.at < CACHE_TTL_MS) {
158
+ return uncRootCache.value;
159
+ }
160
+ const distro = defaultDistro();
161
+ if (!distro) return null;
162
+ let root = null;
163
+ for (const prefix of ['\\\\wsl.localhost\\', '\\\\wsl$\\']) {
164
+ const candidate = prefix + distro;
165
+ try {
166
+ if (fs.existsSync(candidate)) { root = candidate; break; }
167
+ } catch {
168
+ /* an unreachable share throws rather than returning false */
169
+ }
170
+ }
171
+ uncRootCache = { value: root, at: Date.now() };
172
+ return root;
173
+ }
174
+
175
+ /**
176
+ * The distro's $HOME as a path Windows can read, or null.
177
+ *
178
+ * An agent installed in WSL keeps its sign-in where IT lives — ~/.claude,
179
+ * ~/.gemini, ~/.codex inside the distro — so a readiness check that expands "~"
180
+ * against the Windows profile finds nothing and calls a signed-in agent
181
+ * "Not logged in".
182
+ */
183
+ function wslHomeUnc() {
184
+ const home = _shellProbe().home;
185
+ return home ? toUncPath(home) : null;
186
+ }
187
+
188
+ /** Map an in-distro absolute path to the UNC path Windows can stat. */
189
+ function toUncPath(linuxPath) {
190
+ const root = _uncRoot();
191
+ if (!root || !linuxPath || !linuxPath.startsWith('/')) return null;
192
+ return root + linuxPath.replace(/\//g, '\\');
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Windows side: what the distro's login shell would put on PATH
197
+ // ---------------------------------------------------------------------------
198
+
199
+ const PROBE_DELIM = '__OPENAGENTS_WSL__';
200
+ let shellProbeCache = { value: null, at: 0 };
201
+
202
+ /**
203
+ * One wsl.exe call per process (30s TTL) that answers everything later lookups
204
+ * need: the login PATH, $HOME, and the real automount root.
205
+ *
206
+ * `bash -ilc` — INTERACTIVE as well as login — because the version managers
207
+ * that actually hold the agent CLIs write to ~/.bashrc, and Ubuntu's stock
208
+ * ~/.bashrc returns early for a non-interactive shell BEFORE the nvm/fnm block
209
+ * at the bottom ever runs. A plain `-lc` therefore reports a PATH with no nvm
210
+ * in it, which is precisely the install this whole module exists to find.
211
+ * stderr is dropped: an interactive shell with no tty warns about job control.
212
+ */
213
+ function _shellProbe() {
214
+ const empty = { dirs: [], home: null, automount: '/mnt/' };
215
+ if (shellProbeCache.at && Date.now() - shellProbeCache.at < CACHE_TTL_MS) {
216
+ return shellProbeCache.value || empty;
217
+ }
218
+ if (!isWslAvailable()) return empty;
219
+
220
+ const script =
221
+ 'echo ' + PROBE_DELIM + '; echo "$PATH"; echo "$HOME"; ' +
222
+ "wslpath -u 'C:\\' 2>/dev/null || echo /mnt/c/";
223
+
224
+ let out = null;
225
+ for (const argv of [['-e', 'bash', '-ilc', script], ['-e', 'sh', '-lc', script]]) {
226
+ try {
227
+ const raw = execFileSync('wsl.exe', argv, {
228
+ encoding: 'utf-8',
229
+ timeout: PROBE_TIMEOUT_MS,
230
+ windowsHide: true,
231
+ stdio: ['ignore', 'pipe', 'ignore'],
232
+ maxBuffer: 4 * 1024 * 1024,
233
+ });
234
+ const idx = raw.indexOf(PROBE_DELIM);
235
+ if (idx === -1) continue;
236
+ const lines = raw.slice(idx + PROBE_DELIM.length).split(/\r?\n/).map((l) => l.trim());
237
+ const pathLine = lines[1];
238
+ const homeLine = lines[2];
239
+ const cRoot = lines[3];
240
+ if (!pathLine) continue;
241
+ const dirs = [];
242
+ for (const d of pathLine.split(':')) {
243
+ const dir = d.trim();
244
+ // /mnt/<drive> entries are the Windows PATH bounced back by interop.
245
+ // They hold Windows binaries, never the in-distro install we are after.
246
+ if (dir && dir.startsWith('/') && !/^\/mnt\/[a-z]\//i.test(dir) && !dirs.includes(dir)) {
247
+ dirs.push(dir);
248
+ }
249
+ }
250
+ // `wslpath -u 'C:\'` answers /mnt/c/ by default, but /etc/wsl.conf can
251
+ // move the automount root — derive it rather than hardcoding /mnt.
252
+ let automount = '/mnt/';
253
+ const m = /^(\/.*\/)c\/?$/i.exec(cRoot || '');
254
+ if (m) automount = m[1];
255
+ out = { dirs, home: homeLine && homeLine.startsWith('/') ? homeLine : null, automount };
256
+ break;
257
+ } catch {
258
+ /* try the next shell */
259
+ }
260
+ }
261
+ shellProbeCache = { value: out || empty, at: Date.now() };
262
+ return shellProbeCache.value;
263
+ }
264
+
265
+ /**
266
+ * Every in-distro directory an agent CLI can plausibly live in: the login
267
+ * PATH first (it is the ground truth for how the user installed things), then
268
+ * the same curated set paths.js keeps for native Linux, so a CLI whose
269
+ * installer only edited a shell rc file we could not read is still found.
270
+ */
271
+ function wslBinDirs() {
272
+ const probe = _shellProbe();
273
+ const dirs = [...probe.dirs];
274
+ const home = probe.home;
275
+ const push = (d) => { if (d && !dirs.includes(d)) dirs.push(d); };
276
+ if (home) {
277
+ push(home + '/.local/bin');
278
+ push(home + '/.npm-global/bin');
279
+ push(home + '/.openagents/nodejs/node_modules/.bin');
280
+ push(home + '/.cursor/bin');
281
+ push(home + '/.amp/bin');
282
+ push(home + '/.opencode/bin');
283
+ push(home + '/.cargo/bin');
284
+ push(home + '/bin');
285
+ }
286
+ push('/usr/local/bin');
287
+ push('/usr/bin');
288
+ push('/home/linuxbrew/.linuxbrew/bin');
289
+ return dirs;
290
+ }
291
+
292
+ const wslBinaryCache = new Map();
293
+
294
+ /**
295
+ * Resolve an agent binary inside the default distro, or null.
296
+ *
297
+ * Returns the path as the DISTRO sees it, behind the `wsl:` marker
298
+ * (`wsl:/home/u/.local/bin/claude`) — the inner path is what `wsl.exe -e` must
299
+ * be handed. Existence is proved through the UNC mount, so a machine with WSL
300
+ * installed but no distro pays nothing beyond the single cached distro listing.
301
+ *
302
+ * @param {string|string[]} names binary name(s), e.g. 'claude' or ['cursor-agent','agent']
303
+ * @returns {string|null} marked in-distro path
304
+ */
305
+ function resolveWslBinary(names) {
306
+ if (!IS_WINDOWS) return null;
307
+ const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
308
+ if (!list.length) return null;
309
+ const key = list.join('\0');
310
+ const cached = wslBinaryCache.get(key);
311
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.value;
312
+
313
+ let value = null;
314
+ if (isWslAvailable() && _uncRoot()) {
315
+ const dirs = wslBinDirs();
316
+ search:
317
+ for (const dir of dirs) {
318
+ for (const name of list) {
319
+ const linuxPath = dir.replace(/\/+$/, '') + '/' + name;
320
+ const unc = toUncPath(linuxPath);
321
+ if (!unc) continue;
322
+ try {
323
+ if (fs.existsSync(unc) && fs.statSync(unc).isFile()) {
324
+ value = WSL_PREFIX + linuxPath;
325
+ break search;
326
+ }
327
+ } catch {
328
+ /* unreadable path — keep looking */
329
+ }
330
+ }
331
+ }
332
+ }
333
+ wslBinaryCache.set(key, { value, at: Date.now() });
334
+ return value;
335
+ }
336
+
337
+ /**
338
+ * Forget which binaries were found, so an agent installed into the distro while
339
+ * the launcher is already running is picked up. Called from
340
+ * paths.clearBinaryLookupCache(); the distro listing and the login-shell probe
341
+ * survive it deliberately — they each cost a wsl.exe start, and they describe
342
+ * the machine rather than any one install.
343
+ */
344
+ function clearWslBinaryCache() {
345
+ wslBinaryCache.clear();
346
+ }
347
+
348
+ /** Full reset, including the per-process probes. For tests and rare rescans. */
349
+ function clearWslCache() {
350
+ distrosCache = { value: null, at: 0 };
351
+ uncRootCache = { value: null, at: 0 };
352
+ shellProbeCache = { value: null, at: 0 };
353
+ wslBinaryCache.clear();
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Classifying a resolved binary
358
+ // ---------------------------------------------------------------------------
359
+
360
+ /** A binary that lives inside WSL and must be run through wsl.exe. */
361
+ function isWslBinary(bin) {
362
+ return typeof bin === 'string' && bin.startsWith(WSL_PREFIX);
363
+ }
364
+
365
+ /** The in-distro path behind the marker, for anything that has to see it raw. */
366
+ function wslBinaryPath(bin) {
367
+ return isWslBinary(bin) ? bin.slice(WSL_PREFIX.length) : bin;
368
+ }
369
+
370
+ /**
371
+ * A Windows binary seen from inside a distro, classified by whether it can
372
+ * actually be run from here:
373
+ * 'exe' — a real PE image; Linux exec + WSL interop runs it with argv intact.
374
+ * 'shim' — a .cmd/.bat batch file. Only cmd.exe can run one, and cmd.exe
375
+ * rebuilds its command line from a single string, so every &, |, ^,
376
+ * > and " in a prompt would be reinterpreted as shell syntax. We
377
+ * refuse rather than corrupt (or worse, execute) user text.
378
+ *
379
+ * `inWsl` is injectable for the same reason win-exec.ts injects its platform:
380
+ * this branch only ever runs inside a distro, and a test that can only run
381
+ * there is a test that never runs.
382
+ */
383
+ function windowsBinaryKind(bin, inWsl = isRunningInWsl()) {
384
+ if (!inWsl || typeof bin !== 'string') return null;
385
+ if (/\.exe$/i.test(bin)) return 'exe';
386
+ if (/\.(cmd|bat)$/i.test(bin)) return 'shim';
387
+ return null;
388
+ }
389
+
390
+ /** Path as the Windows side sees it (best effort; UNC for in-distro paths). */
391
+ function toWindowsPath(p) {
392
+ if (!p || !p.startsWith('/')) return p;
393
+ const root = isRunningInWsl() ? '/mnt/' : _shellProbe().automount;
394
+ const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
395
+ const m = new RegExp('^' + escaped + '([a-z])(/|$)', 'i').exec(p);
396
+ if (m) {
397
+ return m[1].toUpperCase() + ':\\' + p.slice(m[0].length).replace(/\//g, '\\');
398
+ }
399
+ if (isRunningInWsl()) {
400
+ const distro = process.env.WSL_DISTRO_NAME;
401
+ return distro ? '\\\\wsl.localhost\\' + distro + p.replace(/\//g, '\\') : p;
402
+ }
403
+ return toUncPath(p) || p;
404
+ }
405
+
406
+ /** Path as the distro sees it (best effort; drive letters only). */
407
+ function toWslPath(p) {
408
+ if (!p || !/^[A-Za-z]:[\\/]/.test(p)) return p;
409
+ const root = isRunningInWsl() ? '/mnt/' : _shellProbe().automount;
410
+ return root + p[0].toLowerCase() + '/' + p.slice(3).replace(/\\/g, '/');
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // The spawn bridge
415
+ // ---------------------------------------------------------------------------
416
+
417
+ /**
418
+ * Which environment entries should cross into the distro.
419
+ *
420
+ * Only what the CALLER added on top of this process's own environment: those
421
+ * are the agent's credentials, endpoints and workspace settings, and they are
422
+ * the entire reason a bridged agent would otherwise start up unauthenticated.
423
+ * Anything inherited unchanged from Windows is dropped — the distro has its own
424
+ * PATH/HOME/TEMP, and handing it Windows values breaks the shell before the
425
+ * agent is even reached.
426
+ */
427
+ function _forwardedEnvNames(env) {
428
+ if (!env) return [];
429
+ const names = [];
430
+ for (const [k, v] of Object.entries(env)) {
431
+ if (v == null) continue;
432
+ if (ENV_NEVER_FORWARD.has(k.toUpperCase())) continue;
433
+ if (Object.prototype.hasOwnProperty.call(process.env, k) && process.env[k] === v) continue;
434
+ names.push(k);
435
+ }
436
+ return names;
437
+ }
438
+
439
+ /**
440
+ * WSLENV is the only channel Windows has for handing variables to a distro —
441
+ * without it `wsl.exe -e` starts with a clean Linux environment and every
442
+ * OPENAGENTS_* / *_API_KEY the adapter set is silently dropped.
443
+ *
444
+ * A value that is a Windows path gets the `/p` flag (and a `;`-separated list
445
+ * of them `/l`) so WSL translates it on the way in, instead of handing the
446
+ * agent a `D:\work` that means nothing on the other side.
447
+ */
448
+ function _withWslEnv(env) {
449
+ const out = { ...(env || process.env) };
450
+ const entries = [];
451
+ for (const name of _forwardedEnvNames(out)) {
452
+ const value = String(out[name]);
453
+ const looksLikePath = (s) => /^[A-Za-z]:[\\/]/.test(s);
454
+ let flag = '';
455
+ // The list test comes first: a `C:\a;D:\b` also matches the single-path
456
+ // pattern on its first segment, and tagging it /p would hand the distro one
457
+ // mangled string instead of a translated PATH-style list.
458
+ if (value.includes(';') && value.split(';').filter(Boolean).every(looksLikePath)) {
459
+ flag = '/l';
460
+ } else if (looksLikePath(value)) {
461
+ flag = '/p';
462
+ }
463
+ entries.push(name + flag);
464
+ }
465
+ if (out.WSLENV) entries.unshift(out.WSLENV);
466
+ if (entries.length) out.WSLENV = entries.join(':');
467
+ return out;
468
+ }
469
+
470
+ function _existsDir(p) {
471
+ try { return fs.existsSync(p); } catch { return false; }
472
+ }
473
+
474
+ /**
475
+ * Translate argv entries that are Windows paths into the distro's view of them.
476
+ *
477
+ * Adapters pass real paths on the command line — `--add-dir D:\work`,
478
+ * `--config C:\Users\…\settings.json` — and a Windows path handed to a Linux
479
+ * process is simply a file that does not exist. The entry has to BE a path and
480
+ * that path has to EXIST for it to be rewritten, so ordinary prompt text (which
481
+ * is prose, and does not name a real file) is never touched.
482
+ */
483
+ function _translateArgs(args) {
484
+ return args.map((a) => {
485
+ const s = String(a);
486
+ if (!/^[A-Za-z]:[\\/]/.test(s)) return a;
487
+ if (!_existsDir(s)) return a;
488
+ return toWslPath(s);
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Rewrite a (file, args, opts) triple so it runs on the right side of the
494
+ * boundary. A no-op — the same triple back — for every ordinary local binary,
495
+ * which is why callers can use it unconditionally.
496
+ *
497
+ * @throws {Error} when the binary is a Windows .cmd/.bat shim seen from inside
498
+ * WSL: there is no way to run one without letting cmd.exe re-parse the
499
+ * prompt, so the caller gets an actionable message instead of mangled input.
500
+ *
501
+ * @param {{inWsl?: boolean}} [seam] test injection — see windowsBinaryKind.
502
+ */
503
+ function bridgeSpawn(file, args = [], opts = {}, seam = {}) {
504
+ if (isWslBinary(file)) {
505
+ return {
506
+ file: 'wsl.exe',
507
+ args: ['-e', wslBinaryPath(file), ..._translateArgs(args)],
508
+ opts: {
509
+ ...opts,
510
+ env: _withWslEnv(opts.env),
511
+ // wsl.exe inherits this process's Windows cwd and translates it for the
512
+ // distro, so opts.cwd is handed through untouched — but only if it
513
+ // really exists, since spawn() itself fails ENOENT otherwise and that
514
+ // reads as "the agent isn't installed".
515
+ cwd: opts.cwd && _existsDir(opts.cwd) ? opts.cwd : undefined,
516
+ // A shell would be asked to run a Linux path as a Windows command, and
517
+ // there is no process group to detach into on the far side of wsl.exe.
518
+ shell: false,
519
+ detached: false,
520
+ windowsHide: true,
521
+ },
522
+ };
523
+ }
524
+
525
+ if (windowsBinaryKind(file, seam.inWsl) === 'shim') {
526
+ throw new Error(
527
+ 'Cannot run the Windows CLI ' + file + ' from inside WSL: .cmd/.bat shims have to ' +
528
+ 'go through cmd.exe, which would re-interpret the prompt text. Install the agent ' +
529
+ 'inside this distro (e.g. npm install -g …), or run the launcher on Windows.',
530
+ );
531
+ }
532
+ // A '.exe' needs no rewrite: WSL interop execs a PE image directly and
533
+ // preserves argv exactly, unlike the shim case above.
534
+
535
+ return { file, args, opts };
536
+ }
537
+
538
+ /**
539
+ * child_process.spawn with the boundary handled. Same signature, same return —
540
+ * drop-in for every adapter that spawns an agent CLI.
541
+ */
542
+ function spawn(file, args = [], opts = {}) {
543
+ const bridged = bridgeSpawn(file, args, opts);
544
+ return nodeSpawn(bridged.file, bridged.args, bridged.opts);
545
+ }
546
+
547
+ /**
548
+ * A shell command string that runs `<bin> <args…>` across the boundary, for the
549
+ * execSync-based version/verify probes. Returns null when no bridging applies
550
+ * and the caller should build its own command exactly as before.
551
+ */
552
+ function bridgedCommandString(bin, args = []) {
553
+ if (!isWslBinary(bin)) return null;
554
+ const quoted = [wslBinaryPath(bin), ...args]
555
+ .map((a) => '"' + String(a).replace(/"/g, '\\"') + '"')
556
+ .join(' ');
557
+ return 'wsl.exe -e ' + quoted;
558
+ }
559
+
560
+ module.exports = {
561
+ isRunningInWsl,
562
+ isWslAvailable,
563
+ wslDistros,
564
+ defaultDistro,
565
+ wslBinDirs,
566
+ wslHomeUnc,
567
+ resolveWslBinary,
568
+ clearWslBinaryCache,
569
+ clearWslCache,
570
+ isWslBinary,
571
+ wslBinaryPath,
572
+ windowsBinaryKind,
573
+ toWindowsPath,
574
+ toWslPath,
575
+ toUncPath,
576
+ bridgeSpawn,
577
+ bridgedCommandString,
578
+ spawn,
579
+ };