jevstrudel.git / tools / mcp / browser.mjs
1// Opening a URL in the user's browser, from the proxy.
2//
3// Under WSL the browser is Windows': `rundll32.exe url.dll,FileProtocolHandler
4// <url>` through interop hands the URL to Windows' default browser whole.
5// Measured 2026-09-26 on nyx: it opened Chrome with every query parameter
6// intact, and Chrome's request to http://127.0.0.1:<port>/ reached a listener
7// bound to 127.0.0.1 in the guest (WSL's localhost forwarding). `explorer.exe
8// <url>` exited 1 and opened nothing; `cmd.exe /c start` would split the URL
9// at its first `&`. `windows-exec` (SSH) cannot show a window at all: its
10// logon is session 0.
11//
12// Elsewhere, `xdg-open` or macOS's `open`. Whatever happens, the caller also
13// hands the URL to the user, so a launch that fails costs one click.
14// JEVSTRUDEL_MCP_OPEN_BROWSER=0 opens nothing: for tests, whose own headless
15// browser takes the URL instead.
16import { spawn } from 'node:child_process';
17import { existsSync, readFileSync } from 'node:fs';
18
19const RUNDLL32 = '/mnt/c/Windows/System32/rundll32.exe';
20
21function isWsl() {
22  try {
23    return /microsoft/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8'));
24  } catch {
25    return false;
26  }
27}
28
29function launcher(url) {
30  if (isWsl() && existsSync(RUNDLL32)) return [RUNDLL32, ['url.dll,FileProtocolHandler', url]];
31  if (process.platform === 'darwin') return ['open', [url]];
32  return ['xdg-open', [url]];
33}
34
35// Resolves to how it was opened, or rejects with why not.
36export function openBrowser(url) {
37  if (process.env.JEVSTRUDEL_MCP_OPEN_BROWSER === '0') return Promise.reject(new Error('JEVSTRUDEL_MCP_OPEN_BROWSER=0'));
38  const [command, args] = launcher(url);
39  return new Promise((resolve, reject) => {
40    const child = spawn(command, args, { stdio: 'ignore', detached: true });
41    child.on('error', reject);
42    child.on('exit', (code) => (code === 0 ? resolve(command) : reject(new Error(`${command} exited ${code}`))));
43    child.unref();
44  });
45}