github-router 0.3.31 → 0.3.33

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/dist/main.js CHANGED
@@ -1,19 +1,22 @@
1
1
  #!/usr/bin/env node
2
- import { a as sweepRegistry, c as ensureClaudeConfigMirror, d as writeRuntimeFileSecure, i as registerExitHandlers, l as ensurePaths, n as getInstanceUuid, r as recordWorkerRepo, s as PATHS, t as WorktreeRegistry, u as removeOwnClaudeConfigMirror } from "./lifecycle-BrNqqJZH.js";
2
+ import { c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-Cr2gfGiA.js";
3
+ import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-3OXRVrtQ.js";
3
4
  import { createRequire } from "node:module";
4
5
  import { defineCommand, runMain } from "citty";
5
6
  import consola from "consola";
6
7
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
7
8
  import fs, { readFile, stat } from "node:fs/promises";
8
- import os from "node:os";
9
+ import os, { homedir, platform } from "node:os";
9
10
  import * as path$1 from "node:path";
10
11
  import path from "node:path";
11
12
  import process$1 from "node:process";
12
13
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
13
14
  import { promisify } from "node:util";
14
- import fs$1, { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
15
+ import fs$1, { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
15
16
  import { createInterface } from "node:readline";
16
17
  import Parser from "web-tree-sitter";
18
+ import WebSocket from "ws";
19
+ import { fileURLToPath } from "node:url";
17
20
  import { Type } from "typebox";
18
21
  import "partial-json";
19
22
  import { Compile } from "typebox/compile";
@@ -41,6 +44,7 @@ const state = {
41
44
  rateLimitWait: false,
42
45
  showToken: false,
43
46
  extendedBetas: false,
47
+ browseEnabled: false,
44
48
  sessionId: randomUUID(),
45
49
  machineId: randomBytes(32).toString("hex")
46
50
  };
@@ -2048,8 +2052,8 @@ async function runStructuralPass(opts) {
2048
2052
  consola.debug(`[code_search] structural skip ${relFile} (${size} bytes > cap)`);
2049
2053
  continue;
2050
2054
  }
2051
- let cached = cacheGet(absPath, mtimeMs);
2052
- if (!cached) {
2055
+ let cached$1 = cacheGet(absPath, mtimeMs);
2056
+ if (!cached$1) {
2053
2057
  let source;
2054
2058
  try {
2055
2059
  source = readFileSync(absPath, "utf8");
@@ -2074,23 +2078,23 @@ async function runStructuralPass(opts) {
2074
2078
  } catch (err) {
2075
2079
  consola.debug(`[code_search] tree-sitter parse failed for ${relFile}: ${err.message}`);
2076
2080
  }
2077
- cached = {
2081
+ cached$1 = {
2078
2082
  mtimeMs,
2079
2083
  tree,
2080
2084
  source: tree ? source : null
2081
2085
  };
2082
- cachePut(absPath, cached);
2086
+ cachePut(absPath, cached$1);
2083
2087
  filesParsed += 1;
2084
2088
  }
2085
- if (!cached.tree || !cached.source) continue;
2089
+ if (!cached$1.tree || !cached$1.source) continue;
2086
2090
  for (const entry of entries) {
2087
- const lineStart = lineStartByte(cached.source, entry.hit.line);
2091
+ const lineStart = lineStartByte(cached$1.source, entry.hit.line);
2088
2092
  if (lineStart < 0) continue;
2089
2093
  const matchByteStart = lineStart + entry.hit.match_start;
2090
2094
  const matchByteEnd = lineStart + entry.hit.match_end;
2091
2095
  let node;
2092
2096
  try {
2093
- node = cached.tree.rootNode.descendantForIndex(matchByteStart, matchByteEnd);
2097
+ node = cached$1.tree.rootNode.descendantForIndex(matchByteStart, matchByteEnd);
2094
2098
  } catch {
2095
2099
  node = null;
2096
2100
  }
@@ -2468,6 +2472,1101 @@ function round4(x) {
2468
2472
  return Math.round(x * 1e4) / 1e4;
2469
2473
  }
2470
2474
 
2475
+ //#endregion
2476
+ //#region src/lib/browser-mcp/browser-detect.ts
2477
+ let cached;
2478
+ function probeWindows() {
2479
+ const found = [];
2480
+ const probe = (subkey) => {
2481
+ try {
2482
+ execFileSync("reg.exe", [
2483
+ "query",
2484
+ `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${subkey}`,
2485
+ "/ve"
2486
+ ], {
2487
+ windowsHide: true,
2488
+ timeout: 3e3,
2489
+ stdio: [
2490
+ "ignore",
2491
+ "pipe",
2492
+ "ignore"
2493
+ ]
2494
+ });
2495
+ return true;
2496
+ } catch {
2497
+ try {
2498
+ execFileSync("reg.exe", [
2499
+ "query",
2500
+ `HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${subkey}`,
2501
+ "/ve"
2502
+ ], {
2503
+ windowsHide: true,
2504
+ timeout: 3e3,
2505
+ stdio: [
2506
+ "ignore",
2507
+ "pipe",
2508
+ "ignore"
2509
+ ]
2510
+ });
2511
+ return true;
2512
+ } catch {
2513
+ return false;
2514
+ }
2515
+ }
2516
+ };
2517
+ if (probe("chrome.exe")) found.push("chrome");
2518
+ if (probe("msedge.exe")) found.push("edge");
2519
+ if (!found.includes("chrome")) {
2520
+ const localApp = process$1.env.LOCALAPPDATA;
2521
+ const pf = process$1.env["PROGRAMFILES"];
2522
+ const pf86 = process$1.env["PROGRAMFILES(X86)"];
2523
+ if ([
2524
+ localApp ? path.join(localApp, "Google", "Chrome", "Application", "chrome.exe") : void 0,
2525
+ pf ? path.join(pf, "Google", "Chrome", "Application", "chrome.exe") : void 0,
2526
+ pf86 ? path.join(pf86, "Google", "Chrome", "Application", "chrome.exe") : void 0
2527
+ ].filter((p) => typeof p === "string").some(existsSync)) found.push("chrome");
2528
+ }
2529
+ if (!found.includes("edge")) {
2530
+ const pf86 = process$1.env["PROGRAMFILES(X86)"];
2531
+ const pf = process$1.env["PROGRAMFILES"];
2532
+ if ([pf86 ? path.join(pf86, "Microsoft", "Edge", "Application", "msedge.exe") : void 0, pf ? path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe") : void 0].filter((p) => typeof p === "string").some(existsSync)) found.push("edge");
2533
+ }
2534
+ return found;
2535
+ }
2536
+ function probeMacOS() {
2537
+ const found = [];
2538
+ if (existsSync("/Applications/Google Chrome.app")) found.push("chrome");
2539
+ if (existsSync("/Applications/Microsoft Edge.app")) found.push("edge");
2540
+ return found;
2541
+ }
2542
+ function probeLinux() {
2543
+ const found = [];
2544
+ const which = (cmd) => {
2545
+ try {
2546
+ execFileSync("which", [cmd], {
2547
+ timeout: 2e3,
2548
+ stdio: [
2549
+ "ignore",
2550
+ "pipe",
2551
+ "ignore"
2552
+ ]
2553
+ });
2554
+ return true;
2555
+ } catch {
2556
+ return false;
2557
+ }
2558
+ };
2559
+ if (which("google-chrome") || which("google-chrome-stable") || which("chromium") || which("chromium-browser")) found.push("chrome");
2560
+ if (which("microsoft-edge") || which("microsoft-edge-stable")) found.push("edge");
2561
+ return found;
2562
+ }
2563
+ /**
2564
+ * Returns the supported browsers detected on this host. Result is
2565
+ * cached on first call; restart the proxy to re-detect after a fresh
2566
+ * install.
2567
+ */
2568
+ function detectSupportedBrowsers() {
2569
+ if (cached !== void 0) return cached;
2570
+ let found;
2571
+ switch (process$1.platform) {
2572
+ case "win32":
2573
+ found = probeWindows();
2574
+ break;
2575
+ case "darwin":
2576
+ found = probeMacOS();
2577
+ break;
2578
+ default:
2579
+ found = probeLinux();
2580
+ break;
2581
+ }
2582
+ cached = Object.freeze(found);
2583
+ return cached;
2584
+ }
2585
+ /** Convenience: true iff Chrome OR Edge is detected. */
2586
+ function hasSupportedBrowserInstalled() {
2587
+ return detectSupportedBrowsers().length > 0;
2588
+ }
2589
+
2590
+ //#endregion
2591
+ //#region src/lib/browser-mcp/native-host-installer.ts
2592
+ const NMH_HOST_ID = "com.githubrouter.browser";
2593
+ /**
2594
+ * Compute the deterministic 32-char chrome-extension ID from the
2595
+ * base64-DER-encoded RSA public key in the extension's manifest.json
2596
+ * `key` field. Chrome's derivation:
2597
+ *
2598
+ * 1. base64-decode the key into DER bytes.
2599
+ * 2. SHA-256 the bytes.
2600
+ * 3. Take the first 16 bytes of the digest as 32 hex chars.
2601
+ * 4. Map each hex digit VALUE (0..15) to a letter (a..p). hex value
2602
+ * 0 → 'a', 1 → 'b', …, 15 → 'p'. The result is 32 chars long.
2603
+ *
2604
+ * See https://developer.chrome.com/docs/extensions/reference/manifest/key
2605
+ * for the spec.
2606
+ */
2607
+ function computeExtensionIdFromKey(keyB64) {
2608
+ const der = Buffer.from(keyB64, "base64");
2609
+ const hex = createHash("sha256").update(der).digest().subarray(0, 16).toString("hex");
2610
+ const aCode = "a".charCodeAt(0);
2611
+ let out = "";
2612
+ for (let i = 0; i < hex.length; i++) out += String.fromCharCode(aCode + parseInt(hex[i], 16));
2613
+ return out;
2614
+ }
2615
+ function readManifestKey() {
2616
+ const candidates = [path.resolve(extensionDir(), "manifest.json")];
2617
+ for (const candidate of candidates) try {
2618
+ const raw = readFileSync(candidate, "utf8");
2619
+ const parsed = JSON.parse(raw);
2620
+ if (typeof parsed.key === "string") return parsed.key;
2621
+ } catch {}
2622
+ throw new Error(`native-host-installer: could not read manifest.json from ${candidates.join(", ")}`);
2623
+ }
2624
+ /**
2625
+ * Walk up from a starting directory looking for the github-router
2626
+ * package.json. Returns the package root or undefined if not found
2627
+ * within `maxHops` levels.
2628
+ */
2629
+ function findPackageRoot(startDir, maxHops = 10) {
2630
+ let cur = startDir;
2631
+ for (let i = 0; i < maxHops; i++) {
2632
+ try {
2633
+ const pkgPath = path.join(cur, "package.json");
2634
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
2635
+ if (pkg.name && pkg.name.includes("github-router")) return cur;
2636
+ } catch {}
2637
+ const parent = path.dirname(cur);
2638
+ if (parent === cur) break;
2639
+ cur = parent;
2640
+ }
2641
+ }
2642
+ /**
2643
+ * Resolve the github-router package root. Uses two sources in order:
2644
+ * 1. process.argv[1] — the entrypoint script, walks up from there.
2645
+ * 2. import.meta.url of THIS module, walks up from there.
2646
+ * 3. process.cwd() as last resort.
2647
+ *
2648
+ * Robust across bun (src/main.ts) and node (dist/main.js) launch paths.
2649
+ */
2650
+ function packageRoot() {
2651
+ const entryPath = typeof process$1?.argv?.[1] === "string" ? process$1.argv[1] : void 0;
2652
+ if (entryPath) {
2653
+ const fromEntry = findPackageRoot(path.dirname(entryPath));
2654
+ if (fromEntry) return fromEntry;
2655
+ }
2656
+ try {
2657
+ const fromHere = findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
2658
+ if (fromHere) return fromHere;
2659
+ } catch {}
2660
+ return process$1?.cwd?.() ?? ".";
2661
+ }
2662
+ /**
2663
+ * Absolute path to the extension's source directory. Layouts:
2664
+ *
2665
+ * - Installed via npm: `<package>/dist/browser-ext/` (the published
2666
+ * tarball ships only `dist/`, see package.json "files"). The build
2667
+ * step copies `src/browser-ext/` → `dist/browser-ext/` so the
2668
+ * unpacked extension is available to users.
2669
+ * - Running from this repo: dist/browser-ext/ if it exists (after
2670
+ * `bun run build`), else src/browser-ext/ for fresh-clone-pre-build.
2671
+ *
2672
+ * Override with `GH_ROUTER_BROWSER_EXT_DIR=<abs path>` for development
2673
+ * (lets you point at a working copy of the extension you're editing
2674
+ * without rebuilding between iterations).
2675
+ */
2676
+ function extensionDir() {
2677
+ const override = process$1.env.GH_ROUTER_BROWSER_EXT_DIR;
2678
+ if (override && override.length > 0) return override;
2679
+ const root = packageRoot();
2680
+ const distExt = path.join(root, "dist", "browser-ext");
2681
+ try {
2682
+ if (readFileSync(path.join(distExt, "manifest.json")).length > 0) return distExt;
2683
+ } catch {}
2684
+ return path.join(root, "src", "browser-ext");
2685
+ }
2686
+ /** Absolute path to the bundled bridge entrypoint. */
2687
+ function bridgeBundlePath() {
2688
+ return path.join(packageRoot(), "dist", "browser-bridge", "index.js");
2689
+ }
2690
+ function appBrowserMcpDir() {
2691
+ const dir = path.join(PATHS.APP_DIR, "browser-mcp");
2692
+ mkdirSync(dir, { recursive: true });
2693
+ return dir;
2694
+ }
2695
+ /**
2696
+ * Pick a runtime interpreter for the bridge. The bridge uses Node's
2697
+ * binary-stdin framing for native messaging which Bun handles
2698
+ * differently (Bun closes the bridge prematurely as soon as anything
2699
+ * unexpected lands on stdin). So prefer `node` when available;
2700
+ * fall back to `process.execPath` (which may be bun or a packaged
2701
+ * binary) only if node isn't on PATH.
2702
+ */
2703
+ function resolveBridgeInterpreter() {
2704
+ const probeCmd = platform() === "win32" ? "where" : "which";
2705
+ try {
2706
+ const out = execFileSync(probeCmd, ["node"], {
2707
+ stdio: [
2708
+ "ignore",
2709
+ "pipe",
2710
+ "ignore"
2711
+ ],
2712
+ timeout: 2e3,
2713
+ windowsHide: true
2714
+ }).toString().trim().split(/\r?\n/)[0];
2715
+ if (out) return out;
2716
+ } catch {}
2717
+ return process$1.execPath;
2718
+ }
2719
+ function writeLauncherShim() {
2720
+ const dir = appBrowserMcpDir();
2721
+ const bridgeJs = bridgeBundlePath();
2722
+ const interp = resolveBridgeInterpreter();
2723
+ if (platform() === "win32") {
2724
+ const batPath = path.join(dir, "launcher.bat");
2725
+ writeFileSync(batPath, `@echo off\r\n"${interp}" "${bridgeJs}" %*\r\n`, "utf8");
2726
+ return batPath;
2727
+ }
2728
+ const shPath = path.join(dir, "launcher.sh");
2729
+ writeFileSync(shPath, `#!/usr/bin/env bash\nexec "${interp}" "${bridgeJs}" "$@"\n`, { mode: 493 });
2730
+ try {
2731
+ chmodSync(shPath, 493);
2732
+ } catch {}
2733
+ return shPath;
2734
+ }
2735
+ function nmhPathsFor(browser) {
2736
+ switch (platform()) {
2737
+ case "win32": {
2738
+ const local = process$1.env.LOCALAPPDATA;
2739
+ const base = local ? path.join(local, "github-router", "browser-mcp") : path.join(homedir(), "AppData", "Local", "github-router", "browser-mcp");
2740
+ mkdirSync(base, { recursive: true });
2741
+ return {
2742
+ manifestPath: path.join(base, `${NMH_HOST_ID}.json`),
2743
+ registryKey: browser === "chrome" ? `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NMH_HOST_ID}` : `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NMH_HOST_ID}`
2744
+ };
2745
+ }
2746
+ case "darwin": {
2747
+ const base = browser === "chrome" ? path.join(homedir(), "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts") : path.join(homedir(), "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts");
2748
+ mkdirSync(base, { recursive: true });
2749
+ return { manifestPath: path.join(base, `${NMH_HOST_ID}.json`) };
2750
+ }
2751
+ default: {
2752
+ const base = browser === "chrome" ? path.join(homedir(), ".config", "google-chrome", "NativeMessagingHosts") : path.join(homedir(), ".config", "microsoft-edge", "NativeMessagingHosts");
2753
+ mkdirSync(base, { recursive: true });
2754
+ return { manifestPath: path.join(base, `${NMH_HOST_ID}.json`) };
2755
+ }
2756
+ }
2757
+ }
2758
+ /**
2759
+ * Write the NMH manifest + (Windows) registry key for a given browser.
2760
+ * Returns the manifest path written; throws if any step fails (caller
2761
+ * surfaces as part of install_required.reason).
2762
+ */
2763
+ function installNativeHostFor(browser) {
2764
+ const manifest = {
2765
+ name: NMH_HOST_ID,
2766
+ description: "github-router browser bridge",
2767
+ path: writeLauncherShim(),
2768
+ type: "stdio",
2769
+ allowed_origins: [`chrome-extension://${computeExtensionIdFromKey(readManifestKey())}/`]
2770
+ };
2771
+ const { manifestPath, registryKey } = nmhPathsFor(browser);
2772
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
2773
+ if (platform() !== "win32") try {
2774
+ chmodSync(manifestPath, 420);
2775
+ } catch {}
2776
+ if (registryKey) execFileSync("reg.exe", [
2777
+ "add",
2778
+ registryKey,
2779
+ "/ve",
2780
+ "/t",
2781
+ "REG_SZ",
2782
+ "/d",
2783
+ manifestPath,
2784
+ "/f"
2785
+ ], {
2786
+ windowsHide: true,
2787
+ timeout: 5e3,
2788
+ stdio: [
2789
+ "ignore",
2790
+ "pipe",
2791
+ "pipe"
2792
+ ]
2793
+ });
2794
+ return manifestPath;
2795
+ }
2796
+ /**
2797
+ * Install the NMH manifest for every supported browser detected on
2798
+ * this host. Returns the list of (browser, manifestPath) tuples
2799
+ * actually written so the install_required response can report what
2800
+ * auto-installed.
2801
+ */
2802
+ function installNativeHostForAll(browsers) {
2803
+ const results = [];
2804
+ for (const b of browsers) try {
2805
+ const manifestPath = installNativeHostFor(b);
2806
+ results.push({
2807
+ browser: b,
2808
+ manifestPath
2809
+ });
2810
+ } catch (err) {
2811
+ console.warn(`[browser-mcp] failed to install NMH manifest for ${b}:`, err instanceof Error ? err.message : String(err));
2812
+ }
2813
+ return results;
2814
+ }
2815
+
2816
+ //#endregion
2817
+ //#region src/lib/browser-mcp/install-check.ts
2818
+ function discoveryPath() {
2819
+ return path.join(PATHS.APP_DIR, "browser-mcp", "bridge.json");
2820
+ }
2821
+ function readBridgeDiscovery() {
2822
+ try {
2823
+ const raw = readFileSync(discoveryPath(), "utf8");
2824
+ const parsed = JSON.parse(raw);
2825
+ if (typeof parsed.pid === "number" && typeof parsed.port === "number" && typeof parsed.token === "string" && typeof parsed.startedAt === "number") return parsed;
2826
+ } catch {}
2827
+ }
2828
+ async function probeHealth(port, token, timeoutMs = 500) {
2829
+ const controller = new AbortController();
2830
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2831
+ try {
2832
+ const res = await fetch(`http://127.0.0.1:${port}/health`, {
2833
+ headers: { authorization: `Bearer ${token}` },
2834
+ signal: controller.signal
2835
+ });
2836
+ if (!res.ok) return void 0;
2837
+ return await res.json();
2838
+ } catch {
2839
+ return;
2840
+ } finally {
2841
+ clearTimeout(timer);
2842
+ }
2843
+ }
2844
+ function bridgeBundleExists() {
2845
+ try {
2846
+ readFileSync(bridgeBundlePath());
2847
+ return true;
2848
+ } catch {
2849
+ return false;
2850
+ }
2851
+ }
2852
+ function loadStableExtensionId() {
2853
+ try {
2854
+ const raw = readFileSync(path.join(extensionDir(), "manifest.json"), "utf8");
2855
+ const parsed = JSON.parse(raw);
2856
+ if (typeof parsed.key === "string") return computeExtensionIdFromKey(parsed.key);
2857
+ } catch {}
2858
+ return "unknown";
2859
+ }
2860
+ function buildInstallRequired(reason, autoInstalled) {
2861
+ return {
2862
+ install_required: true,
2863
+ reason,
2864
+ auto_installed: autoInstalled,
2865
+ manual_steps: {
2866
+ load_unpacked_dir: extensionDir(),
2867
+ expected_extension_id: loadStableExtensionId(),
2868
+ instructions: reason === "no_supported_browser" ? "No Chrome or Edge installation was detected on this host. Install one and restart the github-router proxy." : reason === "bridge_bundle_missing" ? "The bridge bundle is missing. Run `bun run build` from the github-router checkout to produce dist/browser-bridge/index.js, then retry." : "Open chrome://extensions (or edge://extensions), enable Developer Mode, click 'Load unpacked', and select the load_unpacked_dir above. Then retry this tool call."
2869
+ }
2870
+ };
2871
+ }
2872
+ /**
2873
+ * Full pre-flight. Returns either `{install_required: false, port,
2874
+ * token, pid}` (bridge ready, extension connected) or an
2875
+ * `install_required` payload the dispatcher hands directly to the
2876
+ * model. Side effect: when reason is `extension_not_loaded`, attempts
2877
+ * to install the NMH manifest for every detected browser so that the
2878
+ * extension can connect immediately on load.
2879
+ */
2880
+ async function ensureBridgeReady() {
2881
+ const browsers = detectSupportedBrowsers();
2882
+ if (browsers.length === 0) return buildInstallRequired("no_supported_browser", []);
2883
+ if (!bridgeBundleExists()) return buildInstallRequired("bridge_bundle_missing", []);
2884
+ const autoInstalled = installNativeHostForAll(browsers).flatMap((r) => [`nmh_manifest_${r.browser}`]);
2885
+ const discovery = readBridgeDiscovery();
2886
+ if (!discovery) return buildInstallRequired("bridge_not_running", autoInstalled);
2887
+ const health = await probeHealth(discovery.port, discovery.token);
2888
+ if (!health || !health.ok) return buildInstallRequired("bridge_not_running", autoInstalled);
2889
+ if (!health.extension_connected) return buildInstallRequired("extension_not_loaded", autoInstalled);
2890
+ return {
2891
+ install_required: false,
2892
+ port: discovery.port,
2893
+ token: discovery.token,
2894
+ pid: discovery.pid
2895
+ };
2896
+ }
2897
+ function installRequiredToolResult(payload) {
2898
+ return {
2899
+ content: [{
2900
+ type: "text",
2901
+ text: JSON.stringify(payload, null, 2)
2902
+ }],
2903
+ isError: true
2904
+ };
2905
+ }
2906
+
2907
+ //#endregion
2908
+ //#region src/lib/browser-mcp/policy.ts
2909
+ const BLOCKED_URL_RE = /^(chrome|edge|brave|opera|vivaldi):\/\/(settings|preferences|extensions|policy|management|password|flags|flag-descriptions)/i;
2910
+ const BLOCKED_VIEW_SOURCE_RE = /^view-source:(chrome|edge):\/\/(settings|extensions)/i;
2911
+ const BLOCKED_OPTIONS_HTML_RE = /^(chrome|edge)-extension:\/\/.*\/(options|popup)\.html/i;
2912
+ const FILE_URL_RE = /^file:/i;
2913
+ function checkUrlPolicy(url) {
2914
+ if (typeof url !== "string" || url.length === 0) return { blocked: false };
2915
+ if (BLOCKED_URL_RE.test(url) || BLOCKED_VIEW_SOURCE_RE.test(url)) return {
2916
+ blocked: true,
2917
+ reason: "Browser-internal pages (settings / preferences / extensions / flags / passwords) are not accessible to the browser MCP. devtools:// is allowed."
2918
+ };
2919
+ if (BLOCKED_OPTIONS_HTML_RE.test(url)) return {
2920
+ blocked: true,
2921
+ reason: "Extension options / popup pages are not accessible to the browser MCP."
2922
+ };
2923
+ if (FILE_URL_RE.test(url) && process.env.GH_ROUTER_BROWSER_ALLOW_FILE_URLS !== "1") return {
2924
+ blocked: true,
2925
+ reason: "file:// URLs are blocked by default. Set GH_ROUTER_BROWSER_ALLOW_FILE_URLS=1 to enable."
2926
+ };
2927
+ return { blocked: false };
2928
+ }
2929
+ /**
2930
+ * Extract URL fields from a tool call's arguments. Returns the first
2931
+ * URL that violates policy, or undefined if all clear. Currently checks
2932
+ * the `url` field (used by browser_open_tab + browser_navigate).
2933
+ */
2934
+ function preflightUrlPolicy(toolName, args) {
2935
+ if (toolName !== "browser_open_tab" && toolName !== "browser_navigate") return { blocked: false };
2936
+ return checkUrlPolicy(args.url);
2937
+ }
2938
+
2939
+ //#endregion
2940
+ //#region src/lib/browser-mcp/dispatch.ts
2941
+ const PER_TOOL_TIMEOUTS = {
2942
+ browser_list_tabs: {
2943
+ defaultMs: 5e3,
2944
+ maxMs: 1e4
2945
+ },
2946
+ browser_open_tab: {
2947
+ defaultMs: 3e4,
2948
+ maxMs: 6e4
2949
+ },
2950
+ browser_close_tab: {
2951
+ defaultMs: 5e3,
2952
+ maxMs: 1e4
2953
+ },
2954
+ browser_navigate: {
2955
+ defaultMs: 3e4,
2956
+ maxMs: 6e4
2957
+ },
2958
+ browser_screenshot: {
2959
+ defaultMs: 15e3,
2960
+ maxMs: 3e4
2961
+ },
2962
+ browser_read_page: {
2963
+ defaultMs: 1e4,
2964
+ maxMs: 3e4
2965
+ },
2966
+ browser_click: {
2967
+ defaultMs: 1e4,
2968
+ maxMs: 3e4
2969
+ },
2970
+ browser_fill: {
2971
+ defaultMs: 1e4,
2972
+ maxMs: 3e4
2973
+ },
2974
+ browser_scroll: {
2975
+ defaultMs: 5e3,
2976
+ maxMs: 1e4
2977
+ },
2978
+ browser_keyboard: {
2979
+ defaultMs: 5e3,
2980
+ maxMs: 1e4
2981
+ },
2982
+ browser_wait: {
2983
+ defaultMs: 1e4,
2984
+ maxMs: 6e4
2985
+ },
2986
+ browser_eval_js: {
2987
+ defaultMs: 5e3,
2988
+ maxMs: 3e4
2989
+ },
2990
+ browser_download: {
2991
+ defaultMs: 6e4,
2992
+ maxMs: 3e5
2993
+ },
2994
+ browser_console_logs: {
2995
+ defaultMs: 5e3,
2996
+ maxMs: 1e4
2997
+ },
2998
+ browser_network_log: {
2999
+ defaultMs: 5e3,
3000
+ maxMs: 1e4
3001
+ }
3002
+ };
3003
+ function pickTimeout(tool) {
3004
+ if (tool in PER_TOOL_TIMEOUTS) return PER_TOOL_TIMEOUTS[tool];
3005
+ return {
3006
+ defaultMs: 1e4,
3007
+ maxMs: 3e4
3008
+ };
3009
+ }
3010
+ /**
3011
+ * Send one request to the bridge over a fresh WebSocket connection.
3012
+ * Resolves to the bridge's response envelope or rejects on timeout /
3013
+ * transport failure. Honors the caller's AbortSignal — when the MCP
3014
+ * client sends notifications/cancelled, the WS is force-closed and
3015
+ * the promise rejects so the slot releases cleanly.
3016
+ */
3017
+ async function bridgeCall(endpoint, tool, args, timeoutMs, signal) {
3018
+ return new Promise((resolve, reject) => {
3019
+ const id = randomUUID();
3020
+ const ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}`, { headers: { authorization: `Bearer ${endpoint.token}` } });
3021
+ let settled = false;
3022
+ const finish = (fn) => {
3023
+ if (settled) return;
3024
+ settled = true;
3025
+ clearTimeout(timer);
3026
+ if (signal) signal.removeEventListener("abort", onAbort);
3027
+ try {
3028
+ ws.close();
3029
+ } catch {}
3030
+ fn();
3031
+ };
3032
+ const onAbort = () => finish(() => reject(/* @__PURE__ */ new Error("aborted")));
3033
+ if (signal) {
3034
+ if (signal.aborted) {
3035
+ onAbort();
3036
+ return;
3037
+ }
3038
+ signal.addEventListener("abort", onAbort, { once: true });
3039
+ }
3040
+ const timer = setTimeout(() => finish(() => reject(/* @__PURE__ */ new Error(`timeout after ${timeoutMs}ms`))), timeoutMs);
3041
+ ws.on("open", () => {
3042
+ ws.send(JSON.stringify({
3043
+ id,
3044
+ tool,
3045
+ args
3046
+ }));
3047
+ });
3048
+ ws.on("message", (raw) => {
3049
+ try {
3050
+ const parsed = JSON.parse(raw.toString());
3051
+ if (parsed && parsed.id === id) finish(() => resolve(parsed));
3052
+ } catch (err) {
3053
+ finish(() => reject(err));
3054
+ }
3055
+ });
3056
+ ws.on("error", (err) => {
3057
+ finish(() => reject(err));
3058
+ });
3059
+ ws.on("close", () => {
3060
+ finish(() => reject(/* @__PURE__ */ new Error("bridge connection closed before response")));
3061
+ });
3062
+ });
3063
+ }
3064
+ /**
3065
+ * Real dispatcher for any browser_* tool. Used by the entries in
3066
+ * src/lib/browser-mcp/index.ts. Returns the standard MCP tool-result
3067
+ * envelope.
3068
+ */
3069
+ async function dispatchBrowserTool(tool, args, signal, opts = {}) {
3070
+ const policy = preflightUrlPolicy(tool, args);
3071
+ if (policy.blocked) return {
3072
+ content: [{
3073
+ type: "text",
3074
+ text: JSON.stringify({
3075
+ blocked: true,
3076
+ reason: policy.reason
3077
+ }, null, 2)
3078
+ }],
3079
+ isError: true
3080
+ };
3081
+ const ready = await ensureBridgeReady();
3082
+ if (ready.install_required) return installRequiredToolResult(ready);
3083
+ const { defaultMs, maxMs } = pickTimeout(tool);
3084
+ const callerTimeout = typeof opts.timeoutMs === "number" && opts.timeoutMs > 0 ? Math.min(opts.timeoutMs, maxMs) : defaultMs;
3085
+ try {
3086
+ const resp = await bridgeCall({
3087
+ port: ready.port,
3088
+ token: ready.token
3089
+ }, tool, args, callerTimeout, signal);
3090
+ if (resp.ok) {
3091
+ const text = typeof resp.data === "string" ? resp.data : JSON.stringify(resp.data, null, 2);
3092
+ logAudit$1({
3093
+ tool,
3094
+ argsBytes: argsByteSize(args),
3095
+ durationMs: 0,
3096
+ profile: typeof args.profile === "string" ? args.profile : "isolated",
3097
+ result: "ok"
3098
+ });
3099
+ return { content: [{
3100
+ type: "text",
3101
+ text
3102
+ }] };
3103
+ }
3104
+ logAudit$1({
3105
+ tool,
3106
+ argsBytes: argsByteSize(args),
3107
+ durationMs: 0,
3108
+ profile: typeof args.profile === "string" ? args.profile : "isolated",
3109
+ result: "bridge_error",
3110
+ error: resp.error
3111
+ });
3112
+ return {
3113
+ content: [{
3114
+ type: "text",
3115
+ text: `${tool} failed: ${resp.error}${resp.code ? ` (${resp.code})` : ""}`
3116
+ }],
3117
+ isError: true
3118
+ };
3119
+ } catch (err) {
3120
+ const message = err instanceof Error ? err.message : String(err);
3121
+ logAudit$1({
3122
+ tool,
3123
+ argsBytes: argsByteSize(args),
3124
+ durationMs: 0,
3125
+ profile: typeof args.profile === "string" ? args.profile : "isolated",
3126
+ result: "exception",
3127
+ error: message
3128
+ });
3129
+ return {
3130
+ content: [{
3131
+ type: "text",
3132
+ text: `${tool} failed: ${message}`
3133
+ }],
3134
+ isError: true
3135
+ };
3136
+ }
3137
+ }
3138
+ function argsByteSize(args) {
3139
+ try {
3140
+ return Buffer.byteLength(JSON.stringify(args), "utf8");
3141
+ } catch {
3142
+ return -1;
3143
+ }
3144
+ }
3145
+ function logAudit$1(record) {
3146
+ if (process.env.GH_ROUTER_LOG_BROWSER_MCP !== "1") return;
3147
+ (async () => {
3148
+ try {
3149
+ const fs$2 = await import("node:fs/promises");
3150
+ const path$2 = await import("node:path");
3151
+ const { PATHS: PATHS$1 } = await import("./paths-Cf3OVCaJ.js");
3152
+ const dir = path$2.join(PATHS$1.APP_DIR, "browser-mcp");
3153
+ await fs$2.mkdir(dir, { recursive: true });
3154
+ const line = JSON.stringify({
3155
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3156
+ ...record
3157
+ }) + "\n";
3158
+ await fs$2.appendFile(path$2.join(dir, "audit.log"), line, "utf8");
3159
+ } catch {}
3160
+ })();
3161
+ }
3162
+
3163
+ //#endregion
3164
+ //#region src/lib/browser-mcp/index.ts
3165
+ /**
3166
+ * Browser-control MCP tools (`browser_*`). All entries route through
3167
+ * `dispatchBrowserTool()` which (1) runs the bridge-layer URL policy
3168
+ * check, (2) runs the install-check pre-flight (returning structured
3169
+ * install_required JSON when the bridge or extension isn't ready),
3170
+ * and (3) opens a WS to the bridge, sends the tool call, awaits the
3171
+ * response with a per-tool timeout.
3172
+ *
3173
+ * Each entry carries `capability: "browser"` so `browserToolsEnabled()`
3174
+ * in `src/routes/mcp/handler.ts` drops them at both list-time and
3175
+ * call-time when the operator hasn't opted in via `--browse` or
3176
+ * `GH_ROUTER_ENABLE_BROWSE=1`.
3177
+ *
3178
+ * v1 surface: 15 tools (Phases 3 + 4a + 4b).
3179
+ */
3180
+ const BROWSER_TOOLS = Object.freeze([
3181
+ {
3182
+ toolNameHttp: "browser_list_tabs",
3183
+ description: "List all open tabs across all browser windows. Returns each tab's id (used by other browser_* tools), URL, title, active flag, and window id.",
3184
+ inputSchema: {
3185
+ type: "object",
3186
+ additionalProperties: false,
3187
+ properties: {}
3188
+ },
3189
+ capability: "browser",
3190
+ async handler(args, signal) {
3191
+ return dispatchBrowserTool("browser_list_tabs", args, signal);
3192
+ }
3193
+ },
3194
+ {
3195
+ toolNameHttp: "browser_open_tab",
3196
+ description: "Open a URL in a new browser tab and wait for the page to finish loading. Returns the new tab's id, final URL after redirects, and HTTP status. Refuses to navigate to browser-internal settings / preferences / extensions / flags pages (returns {blocked: true, reason}); devtools://* is allowed.",
3197
+ inputSchema: {
3198
+ type: "object",
3199
+ required: ["url"],
3200
+ additionalProperties: false,
3201
+ properties: {
3202
+ url: {
3203
+ type: "string",
3204
+ description: "The URL to load. Maximum 8 KB. Settings / preferences / extensions / flags pages are blocked."
3205
+ },
3206
+ reuseActive: {
3207
+ type: "boolean",
3208
+ description: "When true, navigate the currently active tab instead of opening a new one. Default false."
3209
+ }
3210
+ }
3211
+ },
3212
+ capability: "browser",
3213
+ async handler(args, signal) {
3214
+ return dispatchBrowserTool("browser_open_tab", args, signal);
3215
+ }
3216
+ },
3217
+ {
3218
+ toolNameHttp: "browser_close_tab",
3219
+ description: "Close one or more tabs by tab id.",
3220
+ inputSchema: {
3221
+ type: "object",
3222
+ required: ["tabIds"],
3223
+ additionalProperties: false,
3224
+ properties: { tabIds: {
3225
+ type: "array",
3226
+ items: { type: "number" },
3227
+ description: "Array of tab ids to close (from browser_list_tabs)."
3228
+ } }
3229
+ },
3230
+ capability: "browser",
3231
+ async handler(args, signal) {
3232
+ return dispatchBrowserTool("browser_close_tab", args, signal);
3233
+ }
3234
+ },
3235
+ {
3236
+ toolNameHttp: "browser_navigate",
3237
+ description: "Navigate an existing tab: goto a URL, go back, go forward, or reload. Same URL-blocking policy as browser_open_tab.",
3238
+ inputSchema: {
3239
+ type: "object",
3240
+ required: ["tabId", "action"],
3241
+ additionalProperties: false,
3242
+ properties: {
3243
+ tabId: {
3244
+ type: "number",
3245
+ description: "Tab id from browser_list_tabs / browser_open_tab."
3246
+ },
3247
+ action: {
3248
+ type: "string",
3249
+ enum: [
3250
+ "goto",
3251
+ "back",
3252
+ "forward",
3253
+ "reload"
3254
+ ],
3255
+ description: "The navigation action."
3256
+ },
3257
+ url: {
3258
+ type: "string",
3259
+ description: "Required when action=goto. Max 8 KB."
3260
+ },
3261
+ hard: {
3262
+ type: "boolean",
3263
+ description: "Reload only: bypass cache (Ctrl+Shift+R behavior). Default false."
3264
+ }
3265
+ }
3266
+ },
3267
+ capability: "browser",
3268
+ async handler(args, signal) {
3269
+ return dispatchBrowserTool("browser_navigate", args, signal);
3270
+ }
3271
+ },
3272
+ {
3273
+ toolNameHttp: "browser_screenshot",
3274
+ description: "Capture a PNG screenshot of the visible area of a tab. Returns base64-encoded image bytes plus contentType. The tab must be active in its window; this tool auto-activates if needed.",
3275
+ inputSchema: {
3276
+ type: "object",
3277
+ required: ["tabId"],
3278
+ additionalProperties: false,
3279
+ properties: {
3280
+ tabId: {
3281
+ type: "number",
3282
+ description: "Tab id from browser_list_tabs / browser_open_tab."
3283
+ },
3284
+ format: {
3285
+ type: "string",
3286
+ enum: ["png", "jpeg"],
3287
+ description: "Image format. Default 'png'."
3288
+ }
3289
+ }
3290
+ },
3291
+ capability: "browser",
3292
+ async handler(args, signal) {
3293
+ return dispatchBrowserTool("browser_screenshot", args, signal);
3294
+ }
3295
+ },
3296
+ {
3297
+ toolNameHttp: "browser_read_page",
3298
+ description: "Extract rendered page text plus the list of interactive elements (refs, roles, names, bounding boxes). Element refs returned here are intended as the input to a follow-up browser_click / browser_fill / browser_scroll — preferred over CSS selectors because refs are stable across dynamic class names. Text is capped at 256 KiB.",
3299
+ inputSchema: {
3300
+ type: "object",
3301
+ required: ["tabId"],
3302
+ additionalProperties: false,
3303
+ properties: { tabId: {
3304
+ type: "number",
3305
+ description: "Tab id from browser_list_tabs / browser_open_tab."
3306
+ } }
3307
+ },
3308
+ capability: "browser",
3309
+ async handler(args, signal) {
3310
+ return dispatchBrowserTool("browser_read_page", args, signal);
3311
+ }
3312
+ },
3313
+ {
3314
+ toolNameHttp: "browser_click",
3315
+ description: "Click an element by ref (from a prior browser_read_page) or CSS selector. Returns {ok, navigated} where navigated=true if the URL changed within ~300ms of the click.",
3316
+ inputSchema: {
3317
+ type: "object",
3318
+ required: ["tabId"],
3319
+ additionalProperties: false,
3320
+ properties: {
3321
+ tabId: { type: "number" },
3322
+ ref: {
3323
+ type: "string",
3324
+ description: "Element ref from browser_read_page (preferred)."
3325
+ },
3326
+ selector: {
3327
+ type: "string",
3328
+ description: "CSS selector (fallback when no ref)."
3329
+ },
3330
+ button: {
3331
+ type: "string",
3332
+ enum: ["left", "right"],
3333
+ description: "Mouse button. Default 'left'."
3334
+ },
3335
+ clickCount: {
3336
+ type: "number",
3337
+ description: "Number of times to click. Default 1."
3338
+ }
3339
+ }
3340
+ },
3341
+ capability: "browser",
3342
+ async handler(args, signal) {
3343
+ return dispatchBrowserTool("browser_click", args, signal);
3344
+ }
3345
+ },
3346
+ {
3347
+ toolNameHttp: "browser_fill",
3348
+ description: "Type into an input / textarea, select from a dropdown, or toggle a checkbox / radio. Dispatches native input and change events so React-style controlled inputs see the value.",
3349
+ inputSchema: {
3350
+ type: "object",
3351
+ required: ["tabId", "value"],
3352
+ additionalProperties: false,
3353
+ properties: {
3354
+ tabId: { type: "number" },
3355
+ ref: {
3356
+ type: "string",
3357
+ description: "Element ref from browser_read_page (preferred)."
3358
+ },
3359
+ selector: {
3360
+ type: "string",
3361
+ description: "CSS selector (fallback when no ref)."
3362
+ },
3363
+ value: { description: "The value to set. String for inputs / textareas / select option value. Boolean for checkbox / radio. Max 1 MB." },
3364
+ clearFirst: {
3365
+ type: "boolean",
3366
+ description: "Clear the input before typing (default true). No effect on select / checkbox."
3367
+ },
3368
+ pressEnter: {
3369
+ type: "boolean",
3370
+ description: "After typing, dispatch Enter keydown / keyup and call form.requestSubmit if available. Default false."
3371
+ }
3372
+ }
3373
+ },
3374
+ capability: "browser",
3375
+ async handler(args, signal) {
3376
+ return dispatchBrowserTool("browser_fill", args, signal);
3377
+ }
3378
+ },
3379
+ {
3380
+ toolNameHttp: "browser_scroll",
3381
+ description: "Scroll a tab to the top, to the bottom, by a pixel amount, or to a specific element by ref.",
3382
+ inputSchema: {
3383
+ type: "object",
3384
+ required: ["tabId", "target"],
3385
+ additionalProperties: false,
3386
+ properties: {
3387
+ tabId: { type: "number" },
3388
+ target: {
3389
+ type: "string",
3390
+ enum: [
3391
+ "top",
3392
+ "bottom",
3393
+ "pixels",
3394
+ "element"
3395
+ ],
3396
+ description: "Scroll target type."
3397
+ },
3398
+ pixels: {
3399
+ type: "number",
3400
+ description: "Pixel delta when target=pixels. Positive scrolls down, negative scrolls up."
3401
+ },
3402
+ ref: {
3403
+ type: "string",
3404
+ description: "Element ref when target=element. Scrolls so the element is centered in the viewport."
3405
+ }
3406
+ }
3407
+ },
3408
+ capability: "browser",
3409
+ async handler(args, signal) {
3410
+ return dispatchBrowserTool("browser_scroll", args, signal);
3411
+ }
3412
+ },
3413
+ {
3414
+ toolNameHttp: "browser_keyboard",
3415
+ description: "Send a keystroke or chord to the focused element. Use 'Control+L' / 'Command+L' for browser shortcuts, single characters for typing. Uses chrome.debugger so browser-level shortcuts (Ctrl+T, Ctrl+W, etc) actually fire.",
3416
+ inputSchema: {
3417
+ type: "object",
3418
+ required: ["tabId", "keys"],
3419
+ additionalProperties: false,
3420
+ properties: {
3421
+ tabId: { type: "number" },
3422
+ keys: {
3423
+ type: "string",
3424
+ description: "Key or chord. Modifiers (Control, Alt, Shift, Meta / Command) joined with '+'. Example: 'Control+L'."
3425
+ }
3426
+ }
3427
+ },
3428
+ capability: "browser",
3429
+ async handler(args, signal) {
3430
+ return dispatchBrowserTool("browser_keyboard", args, signal);
3431
+ }
3432
+ },
3433
+ {
3434
+ toolNameHttp: "browser_wait",
3435
+ description: "Wait for an element to appear (until='selector'), the tab URL to match a regex (until='url'), or the network to go idle (until='networkIdle' - heuristic: tab status complete + 500ms quiet). Returns {ok: true, elapsedMs} on success, {ok: false, reason: 'timeout'} on miss.",
3436
+ inputSchema: {
3437
+ type: "object",
3438
+ required: ["tabId", "until"],
3439
+ additionalProperties: false,
3440
+ properties: {
3441
+ tabId: { type: "number" },
3442
+ until: {
3443
+ type: "string",
3444
+ enum: [
3445
+ "selector",
3446
+ "url",
3447
+ "networkIdle"
3448
+ ],
3449
+ description: "What to wait for."
3450
+ },
3451
+ selector: {
3452
+ type: "string",
3453
+ description: "CSS selector when until=selector."
3454
+ },
3455
+ urlPattern: {
3456
+ type: "string",
3457
+ description: "JS regex (string form) when until=url."
3458
+ },
3459
+ timeoutMs: {
3460
+ type: "number",
3461
+ description: "Max wait. Default 10000, hard cap 60000."
3462
+ }
3463
+ }
3464
+ },
3465
+ capability: "browser",
3466
+ async handler(args, signal) {
3467
+ return dispatchBrowserTool("browser_wait", args, signal);
3468
+ }
3469
+ },
3470
+ {
3471
+ toolNameHttp: "browser_eval_js",
3472
+ description: "Evaluate a JavaScript expression in the tab's main world (equivalent to typing in the DevTools console). Returns {result} or {error}. Awaits promises returned by the expression. Single narrowly-named escape hatch for behaviors the other tools don't cover.",
3473
+ inputSchema: {
3474
+ type: "object",
3475
+ required: ["tabId", "expression"],
3476
+ additionalProperties: false,
3477
+ properties: {
3478
+ tabId: { type: "number" },
3479
+ expression: {
3480
+ type: "string",
3481
+ description: "JS expression. Max 100 KB. Top-level await NOT supported - wrap in (async () => ...)()."
3482
+ },
3483
+ timeoutMs: {
3484
+ type: "number",
3485
+ description: "Max evaluation time. Default 5000, hard cap 30000."
3486
+ }
3487
+ }
3488
+ },
3489
+ capability: "browser",
3490
+ async handler(args, signal) {
3491
+ return dispatchBrowserTool("browser_eval_js", args, signal);
3492
+ }
3493
+ },
3494
+ {
3495
+ toolNameHttp: "browser_download",
3496
+ description: "Trigger a download by URL and wait for it to complete. Returns {downloadId, path, bytes, mimeType}. The file lands in Chrome's default Downloads dir unless saveAs is given.",
3497
+ inputSchema: {
3498
+ type: "object",
3499
+ required: ["tabId", "url"],
3500
+ additionalProperties: false,
3501
+ properties: {
3502
+ tabId: {
3503
+ type: "number",
3504
+ description: "Tab id is logged but the download itself is window-scoped, not tab-scoped."
3505
+ },
3506
+ source: {
3507
+ type: "string",
3508
+ enum: ["url"],
3509
+ description: "Download source. Only 'url' supported in v1; click-then-wait awaits Phase 5."
3510
+ },
3511
+ url: {
3512
+ type: "string",
3513
+ description: "Direct URL to download. Max 8 KB."
3514
+ },
3515
+ saveAs: {
3516
+ type: "string",
3517
+ description: "Optional filename / relative subdir under Downloads. Conflicts auto-uniquify."
3518
+ }
3519
+ }
3520
+ },
3521
+ capability: "browser",
3522
+ async handler(args, signal) {
3523
+ return dispatchBrowserTool("browser_download", args, signal);
3524
+ }
3525
+ },
3526
+ {
3527
+ toolNameHttp: "browser_console_logs",
3528
+ description: "Drain console messages a tab has emitted since the last call. The first call for a tab attaches chrome.debugger and starts capturing, so very-early-load messages from before the first call are missed; subsequent calls return everything since the previous drain. Buffer is capped at 1000 entries per tab.",
3529
+ inputSchema: {
3530
+ type: "object",
3531
+ required: ["tabId"],
3532
+ additionalProperties: false,
3533
+ properties: {
3534
+ tabId: { type: "number" },
3535
+ level: {
3536
+ type: "string",
3537
+ enum: [
3538
+ "log",
3539
+ "info",
3540
+ "warn",
3541
+ "error",
3542
+ "debug",
3543
+ "all"
3544
+ ],
3545
+ description: "Filter by console level. Default 'all'."
3546
+ }
3547
+ }
3548
+ },
3549
+ capability: "browser",
3550
+ async handler(args, signal) {
3551
+ return dispatchBrowserTool("browser_console_logs", args, signal);
3552
+ }
3553
+ },
3554
+ {
3555
+ toolNameHttp: "browser_network_log",
3556
+ description: "Drain network responses a tab has received since the last call. Same lazy-attach + cap-1000 behavior as browser_console_logs. Returns request URL, method, status, mime type, and timestamp per entry.",
3557
+ inputSchema: {
3558
+ type: "object",
3559
+ required: ["tabId"],
3560
+ additionalProperties: false,
3561
+ properties: { tabId: { type: "number" } }
3562
+ },
3563
+ capability: "browser",
3564
+ async handler(args, signal) {
3565
+ return dispatchBrowserTool("browser_network_log", args, signal);
3566
+ }
3567
+ }
3568
+ ]);
3569
+
2471
3570
  //#endregion
2472
3571
  //#region src/vendor/pi/ai/api-registry.ts
2473
3572
  const apiProviderRegistry = /* @__PURE__ */ new Map();
@@ -2711,8 +3810,8 @@ function coerceWithJsonSchema(value, schema) {
2711
3810
  }
2712
3811
  function getValidator(schema) {
2713
3812
  const key = schema;
2714
- const cached = validatorCache.get(key);
2715
- if (cached) return cached;
3813
+ const cached$1 = validatorCache.get(key);
3814
+ if (cached$1) return cached$1;
2716
3815
  const validator = Compile(schema);
2717
3816
  validatorCache.set(key, validator);
2718
3817
  return validator;
@@ -4405,12 +5504,12 @@ function joinTextChunks(accum, idx) {
4405
5504
  */
4406
5505
  function makeLazyTextPart(chunks) {
4407
5506
  const upTo = chunks.length;
4408
- let cached;
5507
+ let cached$1;
4409
5508
  return {
4410
5509
  type: "text",
4411
5510
  get text() {
4412
- if (cached === void 0) cached = upTo === chunks.length ? chunks.join("") : chunks.slice(0, upTo).join("");
4413
- return cached;
5511
+ if (cached$1 === void 0) cached$1 = upTo === chunks.length ? chunks.join("") : chunks.slice(0, upTo).join("");
5512
+ return cached$1;
4414
5513
  }
4415
5514
  };
4416
5515
  }
@@ -4935,6 +6034,37 @@ function workerToolsEnabled() {
4935
6034
  if (!found) return false;
4936
6035
  return found.capabilities?.supports?.tool_calls === true;
4937
6036
  }
6037
+ /**
6038
+ * Gate for the browser-control MCP tools (`browser_*`).
6039
+ *
6040
+ * Returns true iff BOTH:
6041
+ * 1. The operator opted in via `--browse` (which sets
6042
+ * `state.browseEnabled`) OR the equivalent env var
6043
+ * `GH_ROUTER_ENABLE_BROWSE=1`. Default OFF — browser-control is
6044
+ * side-effectful (mutates the user's browser session, downloads
6045
+ * files, can navigate to phishing URLs the model was prompted with),
6046
+ * so dormant-register is the safe default.
6047
+ * 2. At least one supported Chromium-family browser (Chrome or Edge)
6048
+ * is detected on disk by `hasSupportedBrowserInstalled()`. No
6049
+ * browser → nothing for the bridge to attach to → tools stay
6050
+ * invisible rather than fail at call time. Detection is cached for
6051
+ * the proxy lifetime; a fresh install requires a restart.
6052
+ *
6053
+ * Mirrors the defense-in-depth pattern of `workerToolsEnabled()` /
6054
+ * `standInToolEnabled()`: this same function gates BOTH the
6055
+ * `tools/list` filter in `toolEntries()` AND the call-time rejection in
6056
+ * `handleToolsCall` (returning -32601 for hard-coded tool-name
6057
+ * bypasses), so the two surfaces stay symmetric.
6058
+ *
6059
+ * The env-var check reads `process.env` directly instead of relying
6060
+ * solely on `state.browseEnabled` so a non-`setupAndServe` startup path
6061
+ * (tests, embedded use) can still flip the gate via env. The CLI flag
6062
+ * path is the canonical one for end users.
6063
+ */
6064
+ function browserToolsEnabled() {
6065
+ if (!(state.browseEnabled || process.env.GH_ROUTER_ENABLE_BROWSE === "1")) return false;
6066
+ return hasSupportedBrowserInstalled();
6067
+ }
4938
6068
  function activePersonas() {
4939
6069
  return PERSONAS_READ.filter((p) => !p.requiresGeminiCatalog || geminiAvailable());
4940
6070
  }
@@ -4966,6 +6096,7 @@ function toolEntries() {
4966
6096
  const nonPersonaEntries = NON_PERSONA_MCP_TOOLS.filter((t) => {
4967
6097
  if (t.capability === "worker") return workerToolsEnabled();
4968
6098
  if (t.capability === "stand_in") return standInToolEnabled();
6099
+ if (t.capability === "browser") return browserToolsEnabled();
4969
6100
  return true;
4970
6101
  }).map((t) => ({
4971
6102
  name: t.toolNameHttp,
@@ -5216,6 +6347,7 @@ async function handleToolsCall(body) {
5216
6347
  if (!persona && !nonPersonaTool) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
5217
6348
  if (nonPersonaTool && nonPersonaTool.capability === "worker" && !workerToolsEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
5218
6349
  if (nonPersonaTool && nonPersonaTool.capability === "stand_in" && !standInToolEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
6350
+ if (nonPersonaTool && nonPersonaTool.capability === "browser" && !browserToolsEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
5219
6351
  let personaPrompt;
5220
6352
  let personaContext;
5221
6353
  let personaEffort;
@@ -8975,7 +10107,8 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
8975
10107
  async handler(args, signal) {
8976
10108
  return runStandInToolCall(args, signal);
8977
10109
  }
8978
- }
10110
+ },
10111
+ ...BROWSER_TOOLS
8979
10112
  ]);
8980
10113
  /**
8981
10114
  * Shared closure body for the two worker MCP tools. Validates the
@@ -9746,7 +10879,7 @@ function initProxyFromEnv() {
9746
10879
  //#endregion
9747
10880
  //#region package.json
9748
10881
  var name = "github-router";
9749
- var version = "0.3.31";
10882
+ var version = "0.3.33";
9750
10883
 
9751
10884
  //#endregion
9752
10885
  //#region src/lib/approval.ts
@@ -9963,8 +11096,8 @@ const calculateTokens = (messages, encoder, constants) => {
9963
11096
  */
9964
11097
  const getEncodeChatFunction = async (encoding) => {
9965
11098
  if (encodingCache.has(encoding)) {
9966
- const cached = encodingCache.get(encoding);
9967
- if (cached) return cached;
11099
+ const cached$1 = encodingCache.get(encoding);
11100
+ if (cached$1) return cached$1;
9968
11101
  }
9969
11102
  const supportedEncoding = encoding;
9970
11103
  if (!(supportedEncoding in ENCODING_MAP)) {
@@ -11623,6 +12756,7 @@ async function setupAndServe(options) {
11623
12756
  state.rateLimitWait = options.rateLimitWait;
11624
12757
  state.showToken = options.showToken;
11625
12758
  state.extendedBetas = options.extendedBetas;
12759
+ state.browseEnabled = options.browseEnabled || process.env.GH_ROUTER_ENABLE_BROWSE === "1";
11626
12760
  if (process.env.COPILOT_API_URL) state.copilotApiUrl = process.env.COPILOT_API_URL;
11627
12761
  await ensurePaths();
11628
12762
  await cacheVSCodeVersion();
@@ -11725,6 +12859,11 @@ const sharedServerArgs = {
11725
12859
  type: "boolean",
11726
12860
  default: false,
11727
12861
  description: "Forward extended beta headers for Claude CLI compatibility (default: VS Code-only)"
12862
+ },
12863
+ browse: {
12864
+ type: "boolean",
12865
+ default: false,
12866
+ description: "Enable the browser-control MCP tools (browser_open_tab, browser_screenshot, browser_click, etc.) on /mcp. Requires Chrome or Edge installed; the bundled extension must be loaded on first tool call (the proxy returns install_required with Web Store URLs + a Load Unpacked fallback path). Off by default; can also be enabled with GH_ROUTER_ENABLE_BROWSE=1."
11728
12867
  }
11729
12868
  };
11730
12869
  const allowedAccountTypes = new Set([
@@ -11761,7 +12900,8 @@ function parseSharedArgs(args) {
11761
12900
  githubToken,
11762
12901
  showToken: args["show-token"],
11763
12902
  proxyEnv: args["proxy-env"],
11764
- extendedBetas: args["extended-betas"]
12903
+ extendedBetas: args["extended-betas"],
12904
+ browseEnabled: args.browse
11765
12905
  };
11766
12906
  }
11767
12907
  /**
@@ -12158,8 +13298,8 @@ const debug = defineCommand({
12158
13298
  //#endregion
12159
13299
  //#region src/lib/shell.ts
12160
13300
  function getShell() {
12161
- const { platform, env } = process$1;
12162
- if (platform === "win32") {
13301
+ const { platform: platform$1, env } = process$1;
13302
+ if (platform$1 === "win32") {
12163
13303
  if (env.SHELL) {
12164
13304
  if (env.SHELL.endsWith("zsh")) return "zsh";
12165
13305
  if (env.SHELL.endsWith("fish")) return "fish";