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.
- package/dist/browser-bridge/index.js +22 -1
- package/dist/browser-ext/background.js +351 -77
- package/dist/browser-ext/manifest.json +4 -2
- package/dist/{lifecycle-DU0UI2t5.js → lifecycle-hkBEjHb2.js} +2 -2
- package/dist/{lifecycle-DU0UI2t5.js.map → lifecycle-hkBEjHb2.js.map} +1 -1
- package/dist/{lifecycle-zr19Ot-e.js → lifecycle-pWZ9tKxf.js} +2 -2
- package/dist/main.js +2048 -904
- package/dist/main.js.map +1 -1
- package/dist/paths-CW16Dz9_.js +3 -0
- package/dist/{paths-lwEqM5-i.js → paths-CZvFif-e.js} +23 -3
- package/dist/paths-CZvFif-e.js.map +1 -0
- package/package.json +1 -1
- package/dist/paths-lwEqM5-i.js.map +0 -1
- package/dist/paths-nd-94lLq.js +0 -3
|
@@ -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 —
|
|
25
|
-
//
|
|
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|
|
|
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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
-
//
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
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.
|
|
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": [
|
|
23
|
+
"host_permissions": [
|
|
24
|
+
"<all_urls>"
|
|
25
|
+
]
|
|
24
26
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
310
|
+
//# sourceMappingURL=lifecycle-hkBEjHb2.js.map
|