github-router 0.3.44 → 0.3.52

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.
@@ -3756,6 +3756,7 @@ function sendToBrowser(msg) {
3756
3756
  process$1.stdout.write(frame);
3757
3757
  }
3758
3758
  const token = randomBytes(32).toString("hex");
3759
+ let extensionLoadedVersion;
3759
3760
  const httpServer = createServer((req, res) => {
3760
3761
  if ((req.headers.authorization ?? "") !== `Bearer ${token}`) {
3761
3762
  res.statusCode = 401;
@@ -3767,10 +3768,26 @@ const httpServer = createServer((req, res) => {
3767
3768
  res.end(JSON.stringify({
3768
3769
  ok: true,
3769
3770
  pid: process$1.pid,
3770
- extension_connected: extensionConnected()
3771
+ extension_connected: extensionConnected(),
3772
+ extension_loaded_version: extensionLoadedVersion
3771
3773
  }));
3772
3774
  return;
3773
3775
  }
3776
+ if (req.url === "/reload" && req.method === "POST") {
3777
+ try {
3778
+ sendToBrowser({ type: "__reload__" });
3779
+ res.setHeader("content-type", "application/json");
3780
+ res.end(JSON.stringify({ ok: true }));
3781
+ } catch (err) {
3782
+ res.statusCode = 500;
3783
+ res.setHeader("content-type", "application/json");
3784
+ res.end(JSON.stringify({
3785
+ ok: false,
3786
+ error: err instanceof Error ? err.message : String(err)
3787
+ }));
3788
+ }
3789
+ return;
3790
+ }
3774
3791
  res.statusCode = 404;
3775
3792
  res.end("not found");
3776
3793
  });
@@ -3837,6 +3854,10 @@ function extensionConnected() {
3837
3854
  }
3838
3855
  fromBrowserListeners.push((msg) => {
3839
3856
  lastBrowserContactMs = Date.now();
3857
+ if (msg && typeof msg === "object" && msg.type === "__hello__" && typeof msg.version === "string") {
3858
+ extensionLoadedVersion = msg.version;
3859
+ return;
3860
+ }
3840
3861
  const r = msg;
3841
3862
  if (typeof r.id !== "string") return;
3842
3863
  pendingResolve(r.id, r);
@@ -21,14 +21,28 @@
21
21
  const NATIVE_HOST_NAME = "com.githubrouter.browser"
22
22
 
23
23
  // ---------------------------------------------------------------------
24
- // Navigation policy — list of URL patterns blocked from open / navigate.
25
- // Mirrored in src/lib/browser-mcp/policy.ts (defense in depth).
24
+ // Navigation policy — URL patterns this extension blocks at
25
+ // webNavigation.onBeforeNavigate. This list is INTENTIONALLY NARROWER
26
+ // than the bridge-side regex in src/lib/browser-mcp/policy.ts: the
27
+ // bridge regex only fires for tool-initiated nav (browser_open_tab /
28
+ // browser_navigate) so it can safely block `extensions` without
29
+ // affecting the human user, while THIS regex fires for user-typed URL
30
+ // bar nav too and must preserve human access to chrome://extensions /
31
+ // edge://extensions (needed to reload this extension after package
32
+ // updates).
26
33
  // ---------------------------------------------------------------------
27
34
 
35
+ // `extensions` is intentionally omitted from the extension-side regex —
36
+ // chrome.webNavigation.onBeforeNavigate fires for ALL top-level
37
+ // navigations including the user typing in the URL bar, so including it
38
+ // here would lock the user out of managing the very extension that
39
+ // loads this code (and prevent the reload arrow that auto-update falls
40
+ // back to). Bridge-side policy.ts keeps `extensions` in its regex,
41
+ // which is sufficient because the bridge regex only gates tool-
42
+ // initiated nav (browser_open_tab / browser_navigate).
28
43
  const BLOCKED_URL_RE =
29
- /^(chrome|edge|brave|opera|vivaldi):\/\/(settings|preferences|extensions|policy|management|password|flags|flag-descriptions)/i
30
- const BLOCKED_VIEW_SOURCE_RE =
31
- /^view-source:(chrome|edge):\/\/(settings|extensions)/i
44
+ /^(chrome|edge|brave|opera|vivaldi):\/\/(settings|preferences|policy|management|password|flags|flag-descriptions)/i
45
+ const BLOCKED_VIEW_SOURCE_RE = /^view-source:(chrome|edge):\/\/settings/i
32
46
 
33
47
  function isBlockedUrl(url) {
34
48
  if (typeof url !== "string") return false
@@ -170,40 +184,83 @@ async function toolScreenshot(args) {
170
184
  async function toolReadPage(args) {
171
185
  const tabId = typeof args.tabId === "number" ? args.tabId : undefined
172
186
  if (!tabId) throw new Error("browser_read_page: tabId is required")
187
+ const mode = args.mode === "full" ? "full" : "summary"
173
188
  const [result] = await chrome.scripting.executeScript({
174
189
  target: { tabId },
175
- func: () => {
176
- // Element refs: every interactive element gets an id we return to
177
- // the caller; subsequent click/fill calls reference these refs
178
- // instead of brittle CSS selectors. Refs are stable for the
179
- // lifetime of a single read_page snapshot.
180
- const interactive = "a, button, input, select, textarea, [role='button'], [role='link'], [role='checkbox']"
181
- const els = Array.from(document.querySelectorAll(interactive))
182
- const elements = els.slice(0, 200).map((el, i) => {
183
- const ref = `e${i + 1}`
184
- el.setAttribute("data-gh-router-ref", ref)
185
- const rect = el.getBoundingClientRect()
186
- return {
187
- ref,
188
- role: el.getAttribute("role") || el.tagName.toLowerCase(),
189
- name:
190
- (el.getAttribute("aria-label") ||
191
- el.textContent ||
192
- el.getAttribute("value") ||
193
- el.getAttribute("placeholder") ||
194
- "")
195
- .trim()
196
- .slice(0, 200),
197
- bbox: [Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height)],
190
+ func: (mode) => {
191
+ // Stable ref attribution: every interactive element gets a
192
+ // data-gh-router-ref attribute the model uses for subsequent
193
+ // ref-based actions. Stable for the lifetime of one read_page.
194
+ //
195
+ // Traversal: descend into open shadow roots so web-component-heavy
196
+ // UIs (e.g. modern React apps with shadow encapsulation) surface
197
+ // their interactive elements. Cross-origin iframes are not reached
198
+ // from in-page script that needs CDP and is documented as a
199
+ // future enhancement.
200
+ const INTERACTIVE_ROLES = new Set([
201
+ "button",
202
+ "link",
203
+ "textbox",
204
+ "combobox",
205
+ "checkbox",
206
+ "radio",
207
+ "switch",
208
+ "tab",
209
+ "menuitem",
210
+ "option",
211
+ "slider",
212
+ "searchbox",
213
+ "spinbutton",
214
+ "treeitem",
215
+ ])
216
+ const INTERACTIVE_TAGS = new Set([
217
+ "a",
218
+ "button",
219
+ "input",
220
+ "select",
221
+ "textarea",
222
+ ])
223
+ function isInteractive(el) {
224
+ const role = el.getAttribute("role")
225
+ if (role && INTERACTIVE_ROLES.has(role)) return true
226
+ if (INTERACTIVE_TAGS.has(el.tagName.toLowerCase())) return true
227
+ if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true
228
+ const ti = el.getAttribute("tabindex")
229
+ if (ti !== null && Number.parseInt(ti, 10) >= 0) return true
230
+ return false
231
+ }
232
+ function nameOf(el) {
233
+ const labelledBy = el.getAttribute("aria-labelledby")
234
+ if (labelledBy) {
235
+ const labelEl = document.getElementById(labelledBy)
236
+ if (labelEl) return (labelEl.textContent || "").trim().slice(0, 200)
198
237
  }
199
- })
200
- // Page text: innerText is roughly what a user reads. Cap at
201
- // 256 KiB to keep the response tractable.
202
- const MAX = 256 * 1024
203
- let text = document.body ? document.body.innerText : ""
204
- if (text.length > MAX) text = text.slice(0, MAX)
205
- // Viewport metadata so the model can correlate CSS-px bbox to
206
- // device-px pixels in browser_screenshot (device_px = css_px * dpr).
238
+ return (
239
+ el.getAttribute("aria-label")
240
+ || el.getAttribute("title")
241
+ || (el.textContent || "")
242
+ || el.getAttribute("value")
243
+ || el.getAttribute("placeholder")
244
+ || el.getAttribute("alt")
245
+ || ""
246
+ ).trim().slice(0, 200)
247
+ }
248
+ function walkDeep(root, sink) {
249
+ // Walk every element under root, descending into open shadow
250
+ // roots. Closed shadow roots are intentionally opaque per the
251
+ // web spec; nothing we can do.
252
+ // NodeFilter.SHOW_ELEMENT === 1.
253
+ const walker = root.createTreeWalker
254
+ ? root.createTreeWalker(root, 1)
255
+ : document.createTreeWalker(root, 1)
256
+ let n
257
+ while ((n = walker.nextNode())) {
258
+ sink.push(n)
259
+ if (n.shadowRoot && n.shadowRoot.mode === "open") {
260
+ walkDeep(n.shadowRoot, sink)
261
+ }
262
+ }
263
+ }
207
264
  const viewport = {
208
265
  width: window.innerWidth,
209
266
  height: window.innerHeight,
@@ -211,8 +268,140 @@ async function toolReadPage(args) {
211
268
  scrollX: window.scrollX,
212
269
  scrollY: window.scrollY,
213
270
  }
214
- return { text, elements, viewport }
271
+ function inViewport(rect) {
272
+ return (
273
+ rect.bottom > 0
274
+ && rect.right > 0
275
+ && rect.top < viewport.height
276
+ && rect.left < viewport.width
277
+ && rect.width > 0
278
+ && rect.height > 0
279
+ )
280
+ }
281
+ const allElements = []
282
+ walkDeep(document, allElements)
283
+ const interactive = allElements.filter(isInteractive)
284
+ // Stable refs across snapshots: if an element already carries a
285
+ // data-gh-router-ref from a prior snapshot, keep it. New elements
286
+ // get the next unused counter. Result: ref `e42` refers to the
287
+ // SAME element across reads, so model can do `read_page → click(ref)
288
+ // → read_page` and the ref-to-element binding stays valid.
289
+ const usedRefs = new Set()
290
+ for (const el of interactive) {
291
+ const existing = el.getAttribute("data-gh-router-ref")
292
+ if (existing && /^e\d+$/.test(existing)) usedRefs.add(existing)
293
+ }
294
+ let nextRef = 1
295
+ function nextFreshRef() {
296
+ while (usedRefs.has(`e${nextRef}`)) nextRef++
297
+ const r = `e${nextRef}`
298
+ usedRefs.add(r)
299
+ nextRef++
300
+ return r
301
+ }
302
+ // Summary mode: viewport-visible only; drop nameless non-tag
303
+ // elements (a div with role="button" but no aria-label is noise).
304
+ // Full mode: keep everything, model asked for it.
305
+ const ELEMENT_CAP = 200
306
+ const elements = []
307
+ for (const el of interactive) {
308
+ if (elements.length >= ELEMENT_CAP) break
309
+ const rect = el.getBoundingClientRect()
310
+ if (mode === "summary" && !inViewport(rect)) continue
311
+ const name = nameOf(el)
312
+ const tag = el.tagName.toLowerCase()
313
+ if (mode === "summary" && !name && !INTERACTIVE_TAGS.has(tag)) continue
314
+ let ref = el.getAttribute("data-gh-router-ref")
315
+ if (!ref || !/^e\d+$/.test(ref)) {
316
+ ref = nextFreshRef()
317
+ el.setAttribute("data-gh-router-ref", ref)
318
+ }
319
+ const entry = {
320
+ ref,
321
+ role: el.getAttribute("role") || tag,
322
+ bbox: [
323
+ Math.round(rect.x),
324
+ Math.round(rect.y),
325
+ Math.round(rect.width),
326
+ Math.round(rect.height),
327
+ ],
328
+ }
329
+ if (name) entry.name = name
330
+ elements.push(entry)
331
+ }
332
+ // Text extraction.
333
+ // summary: walk text nodes whose parent is in the viewport; cap
334
+ // at 20 KB. The model sees what a user could read without
335
+ // scrolling. Off-screen content remains reachable via mode:"full".
336
+ // full: 256 KiB innerText cap (legacy behavior).
337
+ let text = ""
338
+ if (mode === "full") {
339
+ const MAX_FULL = 256 * 1024
340
+ text = document.body ? document.body.innerText : ""
341
+ if (text.length > MAX_FULL) text = text.slice(0, MAX_FULL)
342
+ } else {
343
+ const TEXT_CAP = 20 * 1024
344
+ const parts = []
345
+ let total = 0
346
+ const root = document.body || document.documentElement
347
+ if (root) {
348
+ const tw = document.createTreeWalker(root, 4) // NodeFilter.SHOW_TEXT === 4
349
+ let n
350
+ while ((n = tw.nextNode())) {
351
+ const parent = n.parentElement
352
+ if (!parent) continue
353
+ // Skip script/style content.
354
+ const ptag = parent.tagName ? parent.tagName.toLowerCase() : ""
355
+ if (ptag === "script" || ptag === "style" || ptag === "noscript") continue
356
+ const pr = parent.getBoundingClientRect()
357
+ if (!inViewport(pr)) continue
358
+ const t = (n.textContent || "").replace(/\s+/g, " ").trim()
359
+ if (!t) continue
360
+ if (total + t.length + 1 > TEXT_CAP) {
361
+ parts.push(t.slice(0, Math.max(0, TEXT_CAP - total)))
362
+ break
363
+ }
364
+ parts.push(t)
365
+ total += t.length + 1
366
+ }
367
+ }
368
+ text = parts.join("\n")
369
+ }
370
+ // visualSurfaces: canvas + svg of non-trivial size in the
371
+ // viewport. Signals "this region needs vision" to the lead model
372
+ // so it knows to call browser_screenshot / let browser_act
373
+ // auto-escalate when the text-based pickElement misses.
374
+ const visualSurfaces = []
375
+ const VS_MIN = 100
376
+ const canvasNodes = allElements.filter((el) => {
377
+ const t = el.tagName && el.tagName.toLowerCase()
378
+ return t === "canvas" || t === "svg"
379
+ })
380
+ for (const el of canvasNodes) {
381
+ const rect = el.getBoundingClientRect()
382
+ if (rect.width < VS_MIN || rect.height < VS_MIN) continue
383
+ if (!inViewport(rect)) continue
384
+ let ref = el.getAttribute("data-gh-router-ref")
385
+ if (!ref) {
386
+ ref = `v${visualSurfaces.length + 1}`
387
+ el.setAttribute("data-gh-router-ref", ref)
388
+ }
389
+ visualSurfaces.push({
390
+ ref,
391
+ kind: el.tagName.toLowerCase(),
392
+ bbox: [
393
+ Math.round(rect.x),
394
+ Math.round(rect.y),
395
+ Math.round(rect.width),
396
+ Math.round(rect.height),
397
+ ],
398
+ })
399
+ }
400
+ const out = { mode, text, elements, viewport }
401
+ if (visualSurfaces.length > 0) out.visualSurfaces = visualSurfaces
402
+ return out
215
403
  },
404
+ args: [mode],
216
405
  })
217
406
  if (!result || typeof result.result !== "object") {
218
407
  throw new Error("browser_read_page: scripting.executeScript returned nothing")
@@ -232,36 +421,93 @@ async function toolClick(args) {
232
421
  const clickCount = typeof args.clickCount === "number" ? args.clickCount : 1
233
422
  if (!tabId) throw new Error("browser_click: tabId is required")
234
423
  if (!ref && !selector) throw new Error("browser_click: ref or selector is required")
235
- const before = await chrome.tabs.get(tabId)
236
- const urlBefore = before.url
237
- const [result] = await chrome.scripting.executeScript({
238
- target: { tabId },
239
- func: (ref, selector, button, clickCount) => {
240
- const sel = ref ? `[data-gh-router-ref="${ref}"]` : selector
241
- const el = document.querySelector(sel)
242
- if (!el) return { ok: false, error: `element not found: ${sel}` }
243
- // Use native .click() for left-button (handles default action,
244
- // form submission, etc); MouseEvent for right-click context menus.
245
- if (button === "right") {
246
- for (let i = 0; i < clickCount; i++) {
247
- el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 }))
424
+ // Subscribe to nav events BEFORE dispatching the click so a fast
425
+ // click nav transition can't race past us. Cleanup runs in
426
+ // finally so an executeScript throw doesn't leak listeners.
427
+ const navState = watchTabNavigation(tabId)
428
+ try {
429
+ const [result] = await chrome.scripting.executeScript({
430
+ target: { tabId },
431
+ func: (ref, selector, button, clickCount) => {
432
+ const sel = ref ? `[data-gh-router-ref="${ref}"]` : selector
433
+ const el = document.querySelector(sel)
434
+ if (!el) return { ok: false, error: `element not found: ${sel}` }
435
+ // Use native .click() for left-button (handles default action,
436
+ // form submission, etc); MouseEvent for right-click context menus.
437
+ if (button === "right") {
438
+ for (let i = 0; i < clickCount; i++) {
439
+ el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 }))
440
+ }
441
+ } else {
442
+ for (let i = 0; i < clickCount; i++) el.click()
248
443
  }
249
- } else {
250
- for (let i = 0; i < clickCount; i++) el.click()
251
- }
252
- return { ok: true }
253
- },
254
- args: [ref, selector, button, clickCount],
255
- })
256
- if (!result || !result.result || !result.result.ok) {
257
- throw new Error(`browser_click: ${result?.result?.error ?? "execution failed"}`)
444
+ return { ok: true }
445
+ },
446
+ args: [ref, selector, button, clickCount],
447
+ })
448
+ if (!result || !result.result || !result.result.ok) {
449
+ throw new Error(`browser_click: ${result?.result?.error ?? "execution failed"}`)
450
+ }
451
+ // Accurate navigated detection via webNavigation events (replaces the
452
+ // old 300ms URL-poll which missed slow nav and reported navigated:false
453
+ // for clicks that DID navigate but took longer to commit). Wait up to
454
+ // ~150ms for onBeforeNavigate to fire; if it does, then wait up to
455
+ // ~5s for onCommitted to land. If onBeforeNavigate never fires, no
456
+ // navigation was triggered — return immediately, no wasted latency.
457
+ const navigated = await navState.promise
458
+ return { ok: true, navigated }
459
+ } finally {
460
+ navState.cleanup()
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Pre-subscribe to chrome.webNavigation events for an upcoming click
466
+ * on a tab. Returns a {promise, cleanup} pair. The caller fires the
467
+ * click AFTER calling this so the listener can never miss the
468
+ * onBeforeNavigate that the click triggers.
469
+ *
470
+ * Promise resolves to:
471
+ * - true when onCommitted fires for tabId+frameId 0 within ~5s of
472
+ * an onBeforeNavigate also firing on that tab/frame, OR
473
+ * - false when onBeforeNavigate doesn't fire within ~150ms post-call
474
+ * (= no nav triggered by the click).
475
+ *
476
+ * cleanup() removes both listeners — caller MUST invoke from a finally
477
+ * block to avoid leaking event subscriptions on errors.
478
+ */
479
+ function watchTabNavigation(tabId) {
480
+ const NO_NAV_MS = 150
481
+ const COMMIT_MS = 5000
482
+ let onBefore
483
+ let onCommitted
484
+ let resolved = false
485
+ let noNavTimer
486
+ let commitTimer
487
+ const cleanup = () => {
488
+ try { if (onBefore) chrome.webNavigation.onBeforeNavigate.removeListener(onBefore) } catch { /* ignore */ }
489
+ try { if (onCommitted) chrome.webNavigation.onCommitted.removeListener(onCommitted) } catch { /* ignore */ }
490
+ if (noNavTimer) clearTimeout(noNavTimer)
491
+ if (commitTimer) clearTimeout(commitTimer)
258
492
  }
259
- // Brief settle window so clicks that trigger navigation surface in
260
- // the response. 300ms is enough to catch immediate-redirect clicks
261
- // without significantly slowing the tool's tail latency.
262
- await sleep(300)
263
- const after = await chrome.tabs.get(tabId)
264
- return { ok: true, navigated: after.url !== urlBefore }
493
+ const promise = new Promise((resolve) => {
494
+ const settle = (v) => { if (!resolved) { resolved = true; resolve(v) } }
495
+ onCommitted = (details) => {
496
+ if (details.tabId === tabId && details.frameId === 0) settle(true)
497
+ }
498
+ onBefore = (details) => {
499
+ if (details.tabId !== tabId || details.frameId !== 0) return
500
+ // Nav started; switch from "did we get a nav at all" to "wait
501
+ // for commit". If commit doesn't land in COMMIT_MS, assume the
502
+ // nav stuck or was cancelled and report true (a nav DID start).
503
+ if (noNavTimer) { clearTimeout(noNavTimer); noNavTimer = undefined }
504
+ commitTimer = setTimeout(() => settle(true), COMMIT_MS)
505
+ }
506
+ chrome.webNavigation.onBeforeNavigate.addListener(onBefore)
507
+ chrome.webNavigation.onCommitted.addListener(onCommitted)
508
+ noNavTimer = setTimeout(() => settle(false), NO_NAV_MS)
509
+ })
510
+ return { promise, cleanup }
265
511
  }
266
512
 
267
513
  async function toolFill(args) {
@@ -1438,11 +1684,39 @@ function connectBridge() {
1438
1684
  nativePort = undefined
1439
1685
  })
1440
1686
  nativePort = port
1687
+ // Hello frame — lets the bridge associate this connection with a
1688
+ // version. Pre-flight on the proxy side compares this against the
1689
+ // version stamped into dist/browser-ext/manifest.json at build, and
1690
+ // triggers an auto-reload (via __reload__ control frame) when the
1691
+ // package has been updated but the loaded extension is stale.
1692
+ try {
1693
+ port.postMessage({
1694
+ type: "__hello__",
1695
+ version: chrome.runtime.getManifest().version,
1696
+ })
1697
+ } catch (err) {
1698
+ console.warn("[browser-bridge] hello frame failed:", err)
1699
+ }
1441
1700
  return port
1442
1701
  }
1443
1702
 
1444
1703
  async function handleBridgeRequest(req, port) {
1445
- if (!req || typeof req.id !== "string" || typeof req.tool !== "string") return
1704
+ if (!req) return
1705
+ // Control frames — not regular tool dispatches. The bridge sends
1706
+ // these out-of-band; the {id, tool, args} shape doesn't apply.
1707
+ if (req.type === "__reload__") {
1708
+ // chrome.runtime.reload terminates this service worker and starts
1709
+ // a fresh one that re-reads on-disk files. Used by the proxy's
1710
+ // pre-flight when the loaded extension version doesn't match the
1711
+ // version stamped into dist/browser-ext/manifest.json.
1712
+ try {
1713
+ chrome.runtime.reload()
1714
+ } catch (err) {
1715
+ console.warn("[browser-bridge] reload failed:", err)
1716
+ }
1717
+ return
1718
+ }
1719
+ if (typeof req.id !== "string" || typeof req.tool !== "string") return
1446
1720
  const handler = TOOL_HANDLERS[req.tool]
1447
1721
  if (!handler) {
1448
1722
  port.postMessage({ id: req.id, ok: false, error: `unknown tool: ${req.tool}`, code: "unknown_tool" })
@@ -1483,17 +1757,17 @@ chrome.tabs.onUpdated.addListener(() => {
1483
1757
  try { connectBridge() } catch (err) { console.warn("[browser-bridge] onUpdated connect failed:", err) }
1484
1758
  })
1485
1759
 
1486
- // Defense in depth webNavigation listener catches in-page-initiated
1487
- // navigations (JS-driven redirects, meta-refresh, anchor clicks the
1488
- // model didn't go through browser_navigate for). Tool-initiated paths
1489
- // already pre-check via isBlockedUrl() / the bridge-layer policy.ts,
1490
- // so this is the safety net for navigations the bridge can't see.
1491
- //
1492
- // On match: cancel the navigation by routing the tab back to
1493
- // about:blank, AND log a console.error so browser_console_logs can
1494
- // surface "the model tried to navigate to a blocked URL" on the next
1495
- // drain. The cancel happens via chrome.tabs.update there's no
1496
- // onBeforeNavigate "cancel" API in MV3.
1760
+ // webNavigation.onBeforeNavigate fires for ALL top-level navigations
1761
+ // user-typed URL bar entries AND in-page-initiated nav (JS redirect,
1762
+ // meta-refresh, anchor clicks). It does NOT expose transitionType, so
1763
+ // we can't cheaply distinguish initiator at this stage. Consequence:
1764
+ // every URL in BLOCKED_URL_RE is unreachable when this extension is
1765
+ // enabled, including for the human user. `extensions` is deliberately
1766
+ // excluded from BLOCKED_URL_RE to preserve user access to the page
1767
+ // they need to manage this extension; bridge-side policy.ts still
1768
+ // rejects tool-initiated nav there. On match: route the tab back to
1769
+ // about:blank (no onBeforeNavigate cancel API in MV3) and log a
1770
+ // console.error so browser_console_logs can surface it on next drain.
1497
1771
  chrome.webNavigation.onBeforeNavigate.addListener((details) => {
1498
1772
  if (details.frameId !== 0) return // only top-level frame
1499
1773
  if (isBlockedUrl(details.url)) {
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "github-router browser bridge",
4
4
  "short_name": "gh-router-browser",
5
- "version": "0.0.1",
5
+ "version": "0.3.52",
6
6
  "description": "Bridge between Claude (via github-router /mcp) and the browser. Implements tab control, navigation, clicks, form fill, downloads, screenshots, devtools eval. Blocks navigation to chrome://settings.",
7
7
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJElxuBlonBS3TVW9FJN0mGTtShB3L1hoaYf6k39SOr1ogGYmF90EjRxy1i21k9wQQjPf26bcBu/9X67KrQjQV0uB38CaNukgiSeoLjfptN811u+PJHx6BP+jx3Qa6/3VenNPxHC8WEU0GXql8QSjIHEyCwKb6fMASXOK94JyB5Ywov2x8mt/+9ncqBBBMVzf6r5Sagy4PL1XnryLsuADD/vOEkPet8wXgH/Oj7v5tTsQQZ7U1JT51PoDs2BFnXc5v3TkVgZwd32k3ONh+nkDw1Hof+4zwUGOyJE6eMrlYzRlKM4Qxdf9JpavQvqfieAbTRWcyKeclnHeoIfE7cDBQIDAQAB",
8
8
  "background": {
@@ -20,5 +20,7 @@
20
20
  "storage",
21
21
  "alarms"
22
22
  ],
23
- "host_permissions": ["<all_urls>"]
23
+ "host_permissions": [
24
+ "<all_urls>"
25
+ ]
24
26
  }
@@ -1,4 +1,4 @@
1
- import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-lwEqM5-i.js";
1
+ import { l as writeRuntimeFileSecure, t as PATHS } from "./paths-CZvFif-e.js";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
@@ -307,4 +307,4 @@ async function sweepStaleWorktreesAtBoot() {
307
307
 
308
308
  //#endregion
309
309
  export { sweepRegistry as a, registerExitHandlers as i, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, WorktreeRegistry as t };
310
- //# sourceMappingURL=lifecycle-DU0UI2t5.js.map
310
+ //# sourceMappingURL=lifecycle-hkBEjHb2.js.map