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/browser-bridge/index.js +3804 -0
- package/dist/browser-ext/background.js +715 -0
- package/dist/browser-ext/manifest.json +24 -0
- package/dist/lifecycle-3OXRVrtQ.js +292 -0
- package/dist/lifecycle-3OXRVrtQ.js.map +1 -0
- package/dist/{lifecycle-De6QsSv8.js → lifecycle-DxRKANCV.js} +2 -1
- package/dist/main.js +1162 -22
- package/dist/main.js.map +1 -1
- package/dist/paths-Cf3OVCaJ.js +3 -0
- package/dist/{lifecycle-BrNqqJZH.js → paths-Cr2gfGiA.js} +8 -293
- package/dist/paths-Cr2gfGiA.js.map +1 -0
- package/package.json +6 -3
- package/dist/lifecycle-BrNqqJZH.js.map +0 -1
|
@@ -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
|
+
})
|