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.
@@ -0,0 +1,715 @@
1
+ // background.js — MV3 service worker for the github-router browser
2
+ // bridge. Plain JavaScript: service workers don't need a bundler when
3
+ // there are no external imports. Chrome / Edge load this file directly
4
+ // from the unpacked extension dir.
5
+ //
6
+ // Lifecycle: Chrome starts the service worker on demand (when a
7
+ // chrome.runtime event fires) and may tear it down between events. All
8
+ // long-lived state must be persisted to chrome.storage; the native-
9
+ // messaging port itself is recovered lazily on first dispatcher request.
10
+ //
11
+ // Wire protocol (length-prefixed JSON over native messaging, framing
12
+ // handled by chrome.runtime.connectNative):
13
+ //
14
+ // request: { id, tool, args }
15
+ // response: { id, ok: true, data }
16
+ // or { id, ok: false, error, code? }
17
+ //
18
+ // The bridge re-emits the same frames over its localhost WebSocket so
19
+ // the github-router dispatcher doesn't need to translate.
20
+
21
+ const NATIVE_HOST_NAME = "com.githubrouter.browser"
22
+
23
+ // ---------------------------------------------------------------------
24
+ // Navigation policy — list of URL patterns blocked from open / navigate.
25
+ // Mirrored in src/lib/browser-mcp/policy.ts (defense in depth).
26
+ // ---------------------------------------------------------------------
27
+
28
+ 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
32
+
33
+ function isBlockedUrl(url) {
34
+ if (typeof url !== "string") return false
35
+ if (BLOCKED_URL_RE.test(url)) return true
36
+ if (BLOCKED_VIEW_SOURCE_RE.test(url)) return true
37
+ // Allow devtools://, chrome://newtab, about:blank, data:, https:, http:
38
+ return false
39
+ }
40
+
41
+ // ---------------------------------------------------------------------
42
+ // Tool handlers — every browser_* tool the github-router /mcp surface
43
+ // exposes maps to one entry here. Phase 4a ships 6 (open_tab,
44
+ // close_tab, list_tabs, navigate, screenshot, read_page).
45
+ // ---------------------------------------------------------------------
46
+
47
+ async function toolListTabs() {
48
+ const tabs = await chrome.tabs.query({})
49
+ return {
50
+ tabs: tabs.map((t) => ({
51
+ id: t.id,
52
+ url: t.url,
53
+ title: t.title,
54
+ active: t.active,
55
+ windowId: t.windowId,
56
+ })),
57
+ }
58
+ }
59
+
60
+ async function toolOpenTab(args) {
61
+ const url = args.url
62
+ if (typeof url !== "string" || url.length === 0) {
63
+ throw new Error("browser_open_tab: url is required")
64
+ }
65
+ if (isBlockedUrl(url)) {
66
+ return {
67
+ blocked: true,
68
+ reason:
69
+ "browser-internal pages (settings / preferences / extensions / flags) are not accessible to the browser MCP",
70
+ }
71
+ }
72
+ const reuse = args.reuseActive === true
73
+ let tabId
74
+ let finalUrl
75
+ if (reuse) {
76
+ const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
77
+ if (!active || typeof active.id !== "number") {
78
+ throw new Error("browser_open_tab: no active tab to reuse")
79
+ }
80
+ const updated = await chrome.tabs.update(active.id, { url })
81
+ tabId = updated && updated.id
82
+ finalUrl = (updated && updated.url) || url
83
+ } else {
84
+ const created = await chrome.tabs.create({ url })
85
+ tabId = created.id
86
+ finalUrl = created.url || url
87
+ }
88
+ if (typeof tabId !== "number") {
89
+ throw new Error("browser_open_tab: failed to create or update tab")
90
+ }
91
+ // Wait for the page to finish loading (or hit a 15s ceiling).
92
+ await waitForTabComplete(tabId, 15000)
93
+ const t = await chrome.tabs.get(tabId)
94
+ return {
95
+ tabId,
96
+ finalUrl: t.url || finalUrl,
97
+ statusCode: t.status === "complete" ? 200 : 0,
98
+ }
99
+ }
100
+
101
+ async function toolCloseTab(args) {
102
+ const tabIds = Array.isArray(args.tabIds) ? args.tabIds : []
103
+ if (tabIds.length === 0) {
104
+ throw new Error("browser_close_tab: tabIds[] is required")
105
+ }
106
+ await chrome.tabs.remove(tabIds.filter((n) => typeof n === "number"))
107
+ return { closed: tabIds.length }
108
+ }
109
+
110
+ async function toolNavigate(args) {
111
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
112
+ const action = args.action
113
+ const url = args.url
114
+ if (!tabId) throw new Error("browser_navigate: tabId is required")
115
+ if (action === "goto") {
116
+ if (typeof url !== "string") throw new Error("browser_navigate: url required for goto")
117
+ if (isBlockedUrl(url)) {
118
+ return {
119
+ blocked: true,
120
+ reason:
121
+ "browser-internal pages (settings / preferences / extensions / flags) are not accessible to the browser MCP",
122
+ }
123
+ }
124
+ await chrome.tabs.update(tabId, { url })
125
+ } else if (action === "back") {
126
+ await chrome.tabs.goBack(tabId)
127
+ } else if (action === "forward") {
128
+ await chrome.tabs.goForward(tabId)
129
+ } else if (action === "reload") {
130
+ await chrome.tabs.reload(tabId, { bypassCache: !!args.hard })
131
+ } else {
132
+ throw new Error(`browser_navigate: unknown action ${String(action)}`)
133
+ }
134
+ await waitForTabComplete(tabId, 15000)
135
+ const t = await chrome.tabs.get(tabId)
136
+ return { finalUrl: t.url, statusCode: t.status === "complete" ? 200 : 0 }
137
+ }
138
+
139
+ async function toolScreenshot(args) {
140
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
141
+ const format = args.format === "jpeg" ? "jpeg" : "png"
142
+ // captureVisibleTab needs the tab's windowId, not the tab id.
143
+ let windowId
144
+ if (tabId) {
145
+ const tab = await chrome.tabs.get(tabId)
146
+ windowId = tab.windowId
147
+ if (!tab.active) {
148
+ // Must activate the tab to capture it (captureVisibleTab is
149
+ // window-scoped, snapshots the active tab of the named window).
150
+ await chrome.tabs.update(tabId, { active: true })
151
+ // Tiny pause so the renderer has a chance to paint after activation.
152
+ await sleep(150)
153
+ }
154
+ }
155
+ const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { format })
156
+ // dataUrl: "data:image/png;base64,...."
157
+ const m = /^data:([^;]+);base64,(.*)$/.exec(dataUrl)
158
+ if (!m) throw new Error("browser_screenshot: captureVisibleTab returned unexpected shape")
159
+ return { contentType: m[1], dataBase64: m[2] }
160
+ }
161
+
162
+ async function toolReadPage(args) {
163
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
164
+ if (!tabId) throw new Error("browser_read_page: tabId is required")
165
+ const [result] = await chrome.scripting.executeScript({
166
+ target: { tabId },
167
+ func: () => {
168
+ // Element refs: every interactive element gets an id we return to
169
+ // the caller; subsequent click/fill calls reference these refs
170
+ // instead of brittle CSS selectors. Refs are stable for the
171
+ // lifetime of a single read_page snapshot.
172
+ const interactive = "a, button, input, select, textarea, [role='button'], [role='link'], [role='checkbox']"
173
+ const els = Array.from(document.querySelectorAll(interactive))
174
+ const elements = els.slice(0, 200).map((el, i) => {
175
+ const ref = `e${i + 1}`
176
+ el.setAttribute("data-gh-router-ref", ref)
177
+ const rect = el.getBoundingClientRect()
178
+ return {
179
+ ref,
180
+ role: el.getAttribute("role") || el.tagName.toLowerCase(),
181
+ name:
182
+ (el.getAttribute("aria-label") ||
183
+ el.textContent ||
184
+ el.getAttribute("value") ||
185
+ el.getAttribute("placeholder") ||
186
+ "")
187
+ .trim()
188
+ .slice(0, 200),
189
+ bbox: [Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height)],
190
+ }
191
+ })
192
+ // Page text: innerText is roughly what a user reads. Cap at
193
+ // 256 KiB to keep the response tractable.
194
+ const MAX = 256 * 1024
195
+ let text = document.body ? document.body.innerText : ""
196
+ if (text.length > MAX) text = text.slice(0, MAX)
197
+ return { text, elements }
198
+ },
199
+ })
200
+ if (!result || typeof result.result !== "object") {
201
+ throw new Error("browser_read_page: scripting.executeScript returned nothing")
202
+ }
203
+ return result.result
204
+ }
205
+
206
+ // ---------------------------------------------------------------------
207
+ // Phase 4b tools — input / interaction / diagnostics
208
+ // ---------------------------------------------------------------------
209
+
210
+ async function toolClick(args) {
211
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
212
+ const ref = typeof args.ref === "string" ? args.ref : null
213
+ const selector = typeof args.selector === "string" ? args.selector : null
214
+ const button = args.button === "right" ? "right" : "left"
215
+ const clickCount = typeof args.clickCount === "number" ? args.clickCount : 1
216
+ if (!tabId) throw new Error("browser_click: tabId is required")
217
+ if (!ref && !selector) throw new Error("browser_click: ref or selector is required")
218
+ const before = await chrome.tabs.get(tabId)
219
+ const urlBefore = before.url
220
+ const [result] = await chrome.scripting.executeScript({
221
+ target: { tabId },
222
+ func: (ref, selector, button, clickCount) => {
223
+ const sel = ref ? `[data-gh-router-ref="${ref}"]` : selector
224
+ const el = document.querySelector(sel)
225
+ if (!el) return { ok: false, error: `element not found: ${sel}` }
226
+ // Use native .click() for left-button (handles default action,
227
+ // form submission, etc); MouseEvent for right-click context menus.
228
+ if (button === "right") {
229
+ for (let i = 0; i < clickCount; i++) {
230
+ el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 }))
231
+ }
232
+ } else {
233
+ for (let i = 0; i < clickCount; i++) el.click()
234
+ }
235
+ return { ok: true }
236
+ },
237
+ args: [ref, selector, button, clickCount],
238
+ })
239
+ if (!result || !result.result || !result.result.ok) {
240
+ throw new Error(`browser_click: ${result?.result?.error ?? "execution failed"}`)
241
+ }
242
+ // Brief settle window so clicks that trigger navigation surface in
243
+ // the response. 300ms is enough to catch immediate-redirect clicks
244
+ // without significantly slowing the tool's tail latency.
245
+ await sleep(300)
246
+ const after = await chrome.tabs.get(tabId)
247
+ return { ok: true, navigated: after.url !== urlBefore }
248
+ }
249
+
250
+ async function toolFill(args) {
251
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
252
+ const ref = typeof args.ref === "string" ? args.ref : null
253
+ const selector = typeof args.selector === "string" ? args.selector : null
254
+ const value = args.value
255
+ const clearFirst = args.clearFirst !== false
256
+ const pressEnter = args.pressEnter === true
257
+ if (!tabId) throw new Error("browser_fill: tabId is required")
258
+ if (!ref && !selector) throw new Error("browser_fill: ref or selector is required")
259
+ if (typeof value === "undefined") throw new Error("browser_fill: value is required")
260
+ const [result] = await chrome.scripting.executeScript({
261
+ target: { tabId },
262
+ func: (ref, selector, value, clearFirst, pressEnter) => {
263
+ const sel = ref ? `[data-gh-router-ref="${ref}"]` : selector
264
+ const el = document.querySelector(sel)
265
+ if (!el) return { ok: false, error: `element not found: ${sel}` }
266
+ const tag = el.tagName.toLowerCase()
267
+ const type = (el.getAttribute("type") || "").toLowerCase()
268
+ try { el.focus() } catch { /* ignore */ }
269
+ if (tag === "select") {
270
+ el.value = String(value)
271
+ el.dispatchEvent(new Event("change", { bubbles: true }))
272
+ } else if (type === "checkbox" || type === "radio") {
273
+ el.checked = !!value
274
+ el.dispatchEvent(new Event("change", { bubbles: true }))
275
+ } else {
276
+ if (clearFirst) {
277
+ // React-style controlled inputs override .value setter, so go
278
+ // through the native setter so React's onChange fires.
279
+ const proto = tag === "textarea" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
280
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
281
+ if (setter) setter.call(el, "")
282
+ else el.value = ""
283
+ }
284
+ const proto = tag === "textarea" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
285
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
286
+ if (setter) setter.call(el, String(value))
287
+ else el.value = String(value)
288
+ el.dispatchEvent(new InputEvent("input", { bubbles: true, data: String(value) }))
289
+ el.dispatchEvent(new Event("change", { bubbles: true }))
290
+ if (pressEnter) {
291
+ el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }))
292
+ el.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }))
293
+ try { el.form?.requestSubmit?.() } catch { /* ignore */ }
294
+ }
295
+ }
296
+ return { ok: true }
297
+ },
298
+ args: [ref, selector, value, clearFirst, pressEnter],
299
+ })
300
+ if (!result || !result.result || !result.result.ok) {
301
+ throw new Error(`browser_fill: ${result?.result?.error ?? "execution failed"}`)
302
+ }
303
+ return { ok: true }
304
+ }
305
+
306
+ async function toolScroll(args) {
307
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
308
+ const target = args.target
309
+ const pixels = typeof args.pixels === "number" ? args.pixels : 0
310
+ const ref = typeof args.ref === "string" ? args.ref : null
311
+ if (!tabId) throw new Error("browser_scroll: tabId is required")
312
+ if (!["top", "bottom", "pixels", "element"].includes(target)) {
313
+ throw new Error(`browser_scroll: target must be top|bottom|pixels|element, got ${String(target)}`)
314
+ }
315
+ const [result] = await chrome.scripting.executeScript({
316
+ target: { tabId },
317
+ func: (target, pixels, ref) => {
318
+ if (target === "top") window.scrollTo(0, 0)
319
+ else if (target === "bottom") window.scrollTo(0, document.body.scrollHeight)
320
+ else if (target === "pixels") window.scrollBy(0, pixels)
321
+ else if (target === "element" && ref) {
322
+ const el = document.querySelector(`[data-gh-router-ref="${ref}"]`)
323
+ if (el) el.scrollIntoView({ behavior: "auto", block: "center" })
324
+ }
325
+ return { scrollY: window.scrollY, pageHeight: document.body.scrollHeight }
326
+ },
327
+ args: [target, pixels, ref],
328
+ })
329
+ return { ok: true, scrollY: result.result.scrollY, pageHeight: result.result.pageHeight }
330
+ }
331
+
332
+ async function toolKeyboard(args) {
333
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
334
+ const keys = typeof args.keys === "string" ? args.keys : undefined
335
+ if (!tabId) throw new Error("browser_keyboard: tabId is required")
336
+ if (!keys) throw new Error("browser_keyboard: keys (string) is required")
337
+ // Parse "Control+L" → modifiers ["control"] + key "L".
338
+ const parts = keys.split("+")
339
+ const key = parts.pop()
340
+ const mods = parts.map((p) => p.toLowerCase())
341
+ let bits = 0
342
+ if (mods.includes("control") || mods.includes("ctrl")) bits |= 2
343
+ if (mods.includes("alt")) bits |= 1
344
+ if (mods.includes("shift")) bits |= 8
345
+ if (mods.includes("meta") || mods.includes("cmd") || mods.includes("command")) bits |= 4
346
+ // chrome.debugger.Input.dispatchKeyEvent is the only way to simulate
347
+ // real keystrokes that browser shortcuts (Ctrl+L, etc) actually
348
+ // observe. KeyboardEvent dispatched from JS doesn't trigger them.
349
+ //
350
+ // We attach via the shared attachDebuggerOnce helper (and do NOT
351
+ // detach in finally). Detaching here would also tear down the
352
+ // console / network buffers from browser_console_logs and
353
+ // browser_network_log, since those rely on the SAME debugger
354
+ // attachment. The attach stays for the tab's lifetime — chrome's
355
+ // "is being controlled" banner is the visible cost, accepted in
356
+ // exchange for cross-tool composability.
357
+ await attachDebuggerOnce(tabId)
358
+ const winVK = key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0
359
+ await chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {
360
+ type: "keyDown",
361
+ modifiers: bits,
362
+ key,
363
+ text: key.length === 1 ? key : undefined,
364
+ windowsVirtualKeyCode: winVK,
365
+ })
366
+ await chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {
367
+ type: "keyUp",
368
+ modifiers: bits,
369
+ key,
370
+ windowsVirtualKeyCode: winVK,
371
+ })
372
+ return { ok: true }
373
+ }
374
+
375
+ async function toolWait(args) {
376
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
377
+ const until = args.until
378
+ const selector = typeof args.selector === "string" ? args.selector : undefined
379
+ const urlPattern = typeof args.urlPattern === "string" ? args.urlPattern : undefined
380
+ const timeoutMs = Math.min(typeof args.timeoutMs === "number" ? args.timeoutMs : 10000, 60000)
381
+ if (!tabId) throw new Error("browser_wait: tabId is required")
382
+ if (!["selector", "url", "networkIdle"].includes(until)) {
383
+ throw new Error(`browser_wait: until must be selector|url|networkIdle, got ${String(until)}`)
384
+ }
385
+ const start = Date.now()
386
+ const deadline = start + timeoutMs
387
+ while (Date.now() < deadline) {
388
+ if (until === "selector") {
389
+ if (!selector) throw new Error("browser_wait: selector required when until=selector")
390
+ const [r] = await chrome.scripting.executeScript({
391
+ target: { tabId },
392
+ func: (s) => !!document.querySelector(s),
393
+ args: [selector],
394
+ })
395
+ if (r && r.result) return { ok: true, elapsedMs: Date.now() - start }
396
+ } else if (until === "url") {
397
+ if (!urlPattern) throw new Error("browser_wait: urlPattern required when until=url")
398
+ const t = await chrome.tabs.get(tabId)
399
+ try {
400
+ if (new RegExp(urlPattern).test(t.url || "")) {
401
+ return { ok: true, elapsedMs: Date.now() - start }
402
+ }
403
+ } catch (e) {
404
+ throw new Error(`browser_wait: invalid urlPattern regex: ${e.message}`)
405
+ }
406
+ } else {
407
+ // networkIdle — heuristic: status === "complete" + a 500ms quiet window.
408
+ const t = await chrome.tabs.get(tabId)
409
+ if (t.status === "complete") {
410
+ await sleep(500)
411
+ const t2 = await chrome.tabs.get(tabId)
412
+ if (t2.status === "complete") return { ok: true, elapsedMs: Date.now() - start }
413
+ }
414
+ }
415
+ await sleep(200)
416
+ }
417
+ return { ok: false, reason: "timeout", elapsedMs: Date.now() - start }
418
+ }
419
+
420
+ async function toolEvalJs(args) {
421
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
422
+ const expression = typeof args.expression === "string" ? args.expression : undefined
423
+ const timeoutMs = Math.min(typeof args.timeoutMs === "number" ? args.timeoutMs : 5000, 30000)
424
+ if (!tabId) throw new Error("browser_eval_js: tabId is required")
425
+ if (!expression) throw new Error("browser_eval_js: expression (string) is required")
426
+ // chrome.debugger.Runtime.evaluate is equivalent to typing in the
427
+ // DevTools console — runs in the page's main world, supports arbitrary
428
+ // expression strings (MV3 CSP blocks eval/Function in the SW context).
429
+ //
430
+ // Shares the per-tab debugger attach with browser_console_logs and
431
+ // browser_network_log — we attach but DO NOT detach in finally, because
432
+ // detaching would clear those tools' lazy-attached event buffers.
433
+ await attachDebuggerOnce(tabId)
434
+ const r = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
435
+ expression,
436
+ returnByValue: true,
437
+ awaitPromise: true,
438
+ timeout: timeoutMs,
439
+ userGesture: true,
440
+ })
441
+ if (r.exceptionDetails) {
442
+ return { error: r.exceptionDetails.text || r.exceptionDetails.exception?.description || "Runtime exception" }
443
+ }
444
+ // Strip {type, value} wrapper from returnByValue → just the value.
445
+ return { result: r.result?.value }
446
+ }
447
+
448
+ async function toolDownload(args) {
449
+ const url = typeof args.url === "string" ? args.url : undefined
450
+ const saveAs = typeof args.saveAs === "string" ? args.saveAs : undefined
451
+ const source = args.source || "url"
452
+ if (source !== "url") {
453
+ throw new Error("browser_download: only source='url' supported in v1; source='click' awaits Phase 5")
454
+ }
455
+ if (!url) throw new Error("browser_download: url is required when source='url'")
456
+ const downloadId = await chrome.downloads.download({
457
+ url,
458
+ filename: saveAs,
459
+ conflictAction: "uniquify",
460
+ })
461
+ // Wait for the download to reach a terminal state (complete or
462
+ // interrupted). 60s ceiling matches the dispatcher's per-tool default.
463
+ const finalState = await new Promise((resolve) => {
464
+ const listener = (delta) => {
465
+ if (delta.id !== downloadId) return
466
+ if (delta.state?.current === "complete") {
467
+ chrome.downloads.onChanged.removeListener(listener)
468
+ resolve("complete")
469
+ } else if (delta.state?.current === "interrupted") {
470
+ chrome.downloads.onChanged.removeListener(listener)
471
+ resolve("interrupted")
472
+ }
473
+ }
474
+ chrome.downloads.onChanged.addListener(listener)
475
+ setTimeout(() => {
476
+ chrome.downloads.onChanged.removeListener(listener)
477
+ resolve("timeout")
478
+ }, 60_000)
479
+ })
480
+ if (finalState !== "complete") {
481
+ throw new Error(`browser_download: download ${finalState}`)
482
+ }
483
+ const [info] = await chrome.downloads.search({ id: downloadId })
484
+ return {
485
+ downloadId,
486
+ path: info?.filename,
487
+ bytes: info?.fileSize,
488
+ mimeType: info?.mime,
489
+ }
490
+ }
491
+
492
+ // ---------------------------------------------------------------------
493
+ // Debugger-backed event capture (console + network)
494
+ // ---------------------------------------------------------------------
495
+ // Both browser_console_logs and browser_network_log need chrome.debugger
496
+ // attached BEFORE the events of interest fire. We attach lazily on the
497
+ // first call for a given tabId and keep an in-memory ring buffer per
498
+ // tab; subsequent calls drain the buffer. Buffers are capped to avoid
499
+ // runaway memory growth on long-lived tabs.
500
+
501
+ const consoleBuffers = new Map() // tabId → Array<{level, text, ts, sourceUrl, line}>
502
+ const networkBuffers = new Map() // tabId → Array<{url, method, status, requestHeaders, responseHeaders, ts}>
503
+ const attachedTabs = new Set()
504
+ const MAX_BUFFER_ENTRIES = 1000
505
+
506
+ async function attachDebuggerOnce(tabId, opts) {
507
+ if (!attachedTabs.has(tabId)) {
508
+ try { await chrome.debugger.attach({ tabId }, "1.3") } catch { /* may already be attached */ }
509
+ attachedTabs.add(tabId)
510
+ }
511
+ if (opts?.console && !consoleBuffers.has(tabId)) {
512
+ consoleBuffers.set(tabId, [])
513
+ await chrome.debugger.sendCommand({ tabId }, "Runtime.enable")
514
+ }
515
+ if (opts?.network && !networkBuffers.has(tabId)) {
516
+ networkBuffers.set(tabId, [])
517
+ await chrome.debugger.sendCommand({ tabId }, "Network.enable")
518
+ }
519
+ }
520
+
521
+ chrome.debugger.onEvent.addListener((source, method, params) => {
522
+ const tabId = source.tabId
523
+ if (typeof tabId !== "number") return
524
+ if (method === "Runtime.consoleAPICalled") {
525
+ const buf = consoleBuffers.get(tabId)
526
+ if (!buf) return
527
+ const text = (params.args || [])
528
+ .map((a) => (a.value !== undefined ? String(a.value) : (a.description || JSON.stringify(a))))
529
+ .join(" ")
530
+ buf.push({ level: params.type, text, ts: params.timestamp, stackTrace: undefined })
531
+ if (buf.length > MAX_BUFFER_ENTRIES) buf.shift()
532
+ } else if (method === "Network.responseReceived") {
533
+ const buf = networkBuffers.get(tabId)
534
+ if (!buf) return
535
+ buf.push({
536
+ url: params.response?.url,
537
+ method: params.response?.requestHeaders?.[":method"] || "GET",
538
+ status: params.response?.status,
539
+ mimeType: params.response?.mimeType,
540
+ ts: Date.now(),
541
+ })
542
+ if (buf.length > MAX_BUFFER_ENTRIES) buf.shift()
543
+ }
544
+ })
545
+
546
+ chrome.debugger.onDetach.addListener((source) => {
547
+ if (typeof source.tabId === "number") {
548
+ attachedTabs.delete(source.tabId)
549
+ consoleBuffers.delete(source.tabId)
550
+ networkBuffers.delete(source.tabId)
551
+ }
552
+ })
553
+
554
+ async function toolConsoleLogs(args) {
555
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
556
+ const level = typeof args.level === "string" ? args.level : "all"
557
+ if (!tabId) throw new Error("browser_console_logs: tabId is required")
558
+ await attachDebuggerOnce(tabId, { console: true })
559
+ // Give the debugger a moment to start capturing if just attached.
560
+ const buf = consoleBuffers.get(tabId) || []
561
+ const drained = buf.slice()
562
+ consoleBuffers.set(tabId, [])
563
+ const filtered = level === "all" ? drained : drained.filter((e) => e.level === level)
564
+ return { entries: filtered }
565
+ }
566
+
567
+ async function toolNetworkLog(args) {
568
+ const tabId = typeof args.tabId === "number" ? args.tabId : undefined
569
+ if (!tabId) throw new Error("browser_network_log: tabId is required")
570
+ await attachDebuggerOnce(tabId, { network: true })
571
+ const buf = networkBuffers.get(tabId) || []
572
+ const drained = buf.slice()
573
+ networkBuffers.set(tabId, [])
574
+ return { entries: drained }
575
+ }
576
+
577
+ const TOOL_HANDLERS = {
578
+ __ping__: () => ({
579
+ pong: true,
580
+ extension_version: chrome.runtime.getManifest().version,
581
+ }),
582
+ browser_list_tabs: toolListTabs,
583
+ browser_open_tab: toolOpenTab,
584
+ browser_close_tab: toolCloseTab,
585
+ browser_navigate: toolNavigate,
586
+ browser_screenshot: toolScreenshot,
587
+ browser_read_page: toolReadPage,
588
+ browser_click: toolClick,
589
+ browser_fill: toolFill,
590
+ browser_scroll: toolScroll,
591
+ browser_keyboard: toolKeyboard,
592
+ browser_wait: toolWait,
593
+ browser_eval_js: toolEvalJs,
594
+ browser_download: toolDownload,
595
+ browser_console_logs: toolConsoleLogs,
596
+ browser_network_log: toolNetworkLog,
597
+ }
598
+
599
+ // ---------------------------------------------------------------------
600
+ // Helpers
601
+ // ---------------------------------------------------------------------
602
+
603
+ function sleep(ms) {
604
+ return new Promise((r) => setTimeout(r, ms))
605
+ }
606
+
607
+ function waitForTabComplete(tabId, timeoutMs) {
608
+ return new Promise((resolve) => {
609
+ let done = false
610
+ const finish = () => {
611
+ if (done) return
612
+ done = true
613
+ chrome.tabs.onUpdated.removeListener(listener)
614
+ resolve()
615
+ }
616
+ const listener = (id, info) => {
617
+ if (id === tabId && info.status === "complete") finish()
618
+ }
619
+ chrome.tabs.onUpdated.addListener(listener)
620
+ // Also check current state synchronously — the page may already be
621
+ // complete by the time we register the listener.
622
+ chrome.tabs.get(tabId).then((t) => {
623
+ if (t && t.status === "complete") finish()
624
+ }).catch(() => {})
625
+ setTimeout(finish, timeoutMs)
626
+ })
627
+ }
628
+
629
+ // ---------------------------------------------------------------------
630
+ // Native messaging glue
631
+ // ---------------------------------------------------------------------
632
+
633
+ let nativePort
634
+
635
+ function connectBridge() {
636
+ if (nativePort) return nativePort
637
+ const port = chrome.runtime.connectNative(NATIVE_HOST_NAME)
638
+ port.onMessage.addListener((msg) => {
639
+ handleBridgeRequest(msg, port).catch((err) => {
640
+ console.error("[browser-bridge] dispatch crashed:", err)
641
+ })
642
+ })
643
+ port.onDisconnect.addListener(() => {
644
+ const reason = chrome.runtime.lastError ? chrome.runtime.lastError.message : "(no message)"
645
+ console.warn("[browser-bridge] native port disconnected:", reason)
646
+ nativePort = undefined
647
+ })
648
+ nativePort = port
649
+ return port
650
+ }
651
+
652
+ async function handleBridgeRequest(req, port) {
653
+ if (!req || typeof req.id !== "string" || typeof req.tool !== "string") return
654
+ const handler = TOOL_HANDLERS[req.tool]
655
+ if (!handler) {
656
+ port.postMessage({ id: req.id, ok: false, error: `unknown tool: ${req.tool}`, code: "unknown_tool" })
657
+ return
658
+ }
659
+ try {
660
+ const data = await handler(req.args || {})
661
+ port.postMessage({ id: req.id, ok: true, data })
662
+ } catch (err) {
663
+ port.postMessage({ id: req.id, ok: false, error: err && err.message ? err.message : String(err) })
664
+ }
665
+ }
666
+
667
+ chrome.runtime.onInstalled.addListener(() => {
668
+ try { connectBridge() } catch (err) { console.warn("[browser-bridge] onInstalled connect failed:", err) }
669
+ })
670
+ chrome.runtime.onStartup.addListener(() => {
671
+ try { connectBridge() } catch (err) { console.warn("[browser-bridge] onStartup connect failed:", err) }
672
+ })
673
+
674
+ // Top-level eager connect. SW only runs background.js when an event
675
+ // fires (install / startup / message / alarm / tab change), but once
676
+ // it does, this top-level call attempts the native-messaging
677
+ // connection immediately. Idempotent — connectBridge() short-circuits
678
+ // if a port is already open. Wrapped in try/catch so a failure here
679
+ // can't break event-listener registration above.
680
+ try {
681
+ connectBridge()
682
+ } catch (err) {
683
+ console.warn("[browser-bridge] eager connect failed:", err)
684
+ }
685
+
686
+ // Tab-update listener: guarantees the SW wakes up whenever any tab
687
+ // navigates, which is the most reliable wake-up signal in MV3.
688
+ // Without this, the SW may stay dormant until the user explicitly
689
+ // interacts with the extension UI.
690
+ chrome.tabs.onUpdated.addListener(() => {
691
+ try { connectBridge() } catch (err) { console.warn("[browser-bridge] onUpdated connect failed:", err) }
692
+ })
693
+
694
+ // Defense in depth — webNavigation listener catches in-page-initiated
695
+ // navigations (JS-driven redirects, meta-refresh, anchor clicks the
696
+ // model didn't go through browser_navigate for). Tool-initiated paths
697
+ // already pre-check via isBlockedUrl() / the bridge-layer policy.ts,
698
+ // so this is the safety net for navigations the bridge can't see.
699
+ //
700
+ // On match: cancel the navigation by routing the tab back to
701
+ // about:blank, AND log a console.error so browser_console_logs can
702
+ // surface "the model tried to navigate to a blocked URL" on the next
703
+ // drain. The cancel happens via chrome.tabs.update — there's no
704
+ // onBeforeNavigate "cancel" API in MV3.
705
+ chrome.webNavigation.onBeforeNavigate.addListener((details) => {
706
+ if (details.frameId !== 0) return // only top-level frame
707
+ if (isBlockedUrl(details.url)) {
708
+ try {
709
+ chrome.tabs.update(details.tabId, { url: "about:blank" })
710
+ } catch (err) {
711
+ console.warn("[browser-bridge] could not cancel blocked nav:", err)
712
+ }
713
+ console.error(`[browser-bridge] policy_blocked: ${details.url}`)
714
+ }
715
+ })