github-router 0.3.52 → 0.3.66
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 +17 -1
- package/dist/browser-ext/background.js +358 -1
- package/dist/browser-ext/manifest.json +2 -1
- package/dist/browser-ext/snapshot-cdp.js +438 -0
- package/dist/browser-ext/snapshot.js +101 -0
- package/dist/main.js +1148 -69
- package/dist/main.js.map +1 -1
- package/package.json +18 -18
|
@@ -3769,7 +3769,13 @@ const httpServer = createServer((req, res) => {
|
|
|
3769
3769
|
ok: true,
|
|
3770
3770
|
pid: process$1.pid,
|
|
3771
3771
|
extension_connected: extensionConnected(),
|
|
3772
|
-
extension_loaded_version: extensionLoadedVersion
|
|
3772
|
+
extension_loaded_version: extensionLoadedVersion,
|
|
3773
|
+
humanlike_tabs: Array.from(humanlikeTabs.entries()).map(([tabId, v]) => ({
|
|
3774
|
+
tabId,
|
|
3775
|
+
vendor: v.vendor,
|
|
3776
|
+
signal: v.signal,
|
|
3777
|
+
since: v.since
|
|
3778
|
+
}))
|
|
3773
3779
|
}));
|
|
3774
3780
|
return;
|
|
3775
3781
|
}
|
|
@@ -3858,10 +3864,20 @@ fromBrowserListeners.push((msg) => {
|
|
|
3858
3864
|
extensionLoadedVersion = msg.version;
|
|
3859
3865
|
return;
|
|
3860
3866
|
}
|
|
3867
|
+
if (msg && typeof msg === "object" && msg.type === "__botDetected__" && typeof msg.tabId === "number") {
|
|
3868
|
+
const m = msg;
|
|
3869
|
+
humanlikeTabs.set(m.tabId, {
|
|
3870
|
+
vendor: typeof m.vendor === "string" ? m.vendor : "unknown",
|
|
3871
|
+
signal: typeof m.signal === "string" ? m.signal : "",
|
|
3872
|
+
since: Date.now()
|
|
3873
|
+
});
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3861
3876
|
const r = msg;
|
|
3862
3877
|
if (typeof r.id !== "string") return;
|
|
3863
3878
|
pendingResolve(r.id, r);
|
|
3864
3879
|
});
|
|
3880
|
+
const humanlikeTabs = /* @__PURE__ */ new Map();
|
|
3865
3881
|
httpServer.listen(0, "127.0.0.1", () => {
|
|
3866
3882
|
const addr = httpServer.address();
|
|
3867
3883
|
if (!addr || typeof addr === "string") {
|
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
|
|
21
21
|
const NATIVE_HOST_NAME = "com.githubrouter.browser"
|
|
22
22
|
|
|
23
|
+
// Snapshot cache + invalidation lives in a sibling module so the
|
|
24
|
+
// matcher-cascade work in Phase 2 can consume it without dragging in
|
|
25
|
+
// the entire 1700-line background.js dispatcher.
|
|
26
|
+
import {
|
|
27
|
+
captureSnapshot,
|
|
28
|
+
invalidateSnapshot,
|
|
29
|
+
} from "./snapshot.js"
|
|
30
|
+
import { extractSnapshotCDP } from "./snapshot-cdp.js"
|
|
31
|
+
|
|
23
32
|
// ---------------------------------------------------------------------
|
|
24
33
|
// Navigation policy — URL patterns this extension blocks at
|
|
25
34
|
// webNavigation.onBeforeNavigate. This list is INTENTIONALLY NARROWER
|
|
@@ -185,6 +194,40 @@ async function toolReadPage(args) {
|
|
|
185
194
|
const tabId = typeof args.tabId === "number" ? args.tabId : undefined
|
|
186
195
|
if (!tabId) throw new Error("browser_read_page: tabId is required")
|
|
187
196
|
const mode = args.mode === "full" ? "full" : "summary"
|
|
197
|
+
const refresh = args.refresh === true
|
|
198
|
+
// Phase 1c-CDP: try the CDP `Accessibility.getFullAXTree`-based
|
|
199
|
+
// extractor first. Better cross-origin iframe coverage, real
|
|
200
|
+
// platform-computed accessible names, AX-tree-flagged hidden /
|
|
201
|
+
// disabled state. Falls back to the legacy DOM walker when CDP
|
|
202
|
+
// attach fails (enterprise DeveloperToolsAvailability=2, DevTools
|
|
203
|
+
// already open on the tab, sandbox restriction). The fallback path
|
|
204
|
+
// produces a strict-subset shape so consumers don't have to branch.
|
|
205
|
+
const extractor = async (tId, ext) => {
|
|
206
|
+
try {
|
|
207
|
+
return await extractSnapshotCDP(tId, ext, {
|
|
208
|
+
attachDebugger: attachDebuggerOnce,
|
|
209
|
+
sendCommand: (id, method, params) => chrome.debugger.sendCommand({ tabId: id }, method, params),
|
|
210
|
+
})
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const msg = err && err.message ? err.message : String(err)
|
|
213
|
+
console.warn(`[browser-mcp/snapshot] CDP extractor failed, falling back to legacy: ${msg}`)
|
|
214
|
+
return await extractSnapshotLegacy(tId, ext)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return captureSnapshot(tabId, { mode, refresh }, extractor)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Legacy `document.querySelectorAll`-based extractor. Stays as the
|
|
222
|
+
* default extractor until Phase 1b-CDP lands; will become the fallback
|
|
223
|
+
* path when CDP attach fails (enterprise policy, DevTools open on the
|
|
224
|
+
* tab, etc.). The implementation runs in the page world via
|
|
225
|
+
* chrome.scripting.executeScript and returns a PageSnapshot-shaped
|
|
226
|
+
* object that snapshot.captureSnapshot caches and returns to the
|
|
227
|
+
* caller.
|
|
228
|
+
*/
|
|
229
|
+
async function extractSnapshotLegacy(tabId, opts) {
|
|
230
|
+
const mode = opts?.mode === "full" ? "full" : "summary"
|
|
188
231
|
const [result] = await chrome.scripting.executeScript({
|
|
189
232
|
target: { tabId },
|
|
190
233
|
func: (mode) => {
|
|
@@ -303,6 +346,97 @@ async function toolReadPage(args) {
|
|
|
303
346
|
// elements (a div with role="button" but no aria-label is noise).
|
|
304
347
|
// Full mode: keep everything, model asked for it.
|
|
305
348
|
const ELEMENT_CAP = 200
|
|
349
|
+
const LANDMARK_ROLES = new Set([
|
|
350
|
+
"dialog", "alertdialog", "region", "navigation", "main",
|
|
351
|
+
"form", "search", "complementary", "banner", "contentinfo",
|
|
352
|
+
])
|
|
353
|
+
const LANDMARK_TAGS = new Set([
|
|
354
|
+
"dialog", "form", "nav", "main", "header", "footer", "aside", "section",
|
|
355
|
+
])
|
|
356
|
+
// Pre-mint refs for landmark ancestors so child elements can
|
|
357
|
+
// cite parent refs without a second walk.
|
|
358
|
+
function landmarkRefsFor(el) {
|
|
359
|
+
const refs = []
|
|
360
|
+
let cur = el.parentElement
|
|
361
|
+
let depth = 0
|
|
362
|
+
while (cur && depth < 12 && refs.length < 4) {
|
|
363
|
+
const role = cur.getAttribute && cur.getAttribute("role")
|
|
364
|
+
const ctag = cur.tagName && cur.tagName.toLowerCase()
|
|
365
|
+
const isLandmark = (role && LANDMARK_ROLES.has(role)) || LANDMARK_TAGS.has(ctag)
|
|
366
|
+
if (isLandmark) {
|
|
367
|
+
let r = cur.getAttribute("data-gh-router-ref")
|
|
368
|
+
if (!r || !/^e\d+$/.test(r)) {
|
|
369
|
+
r = nextFreshRef()
|
|
370
|
+
cur.setAttribute("data-gh-router-ref", r)
|
|
371
|
+
}
|
|
372
|
+
refs.push(r)
|
|
373
|
+
}
|
|
374
|
+
cur = cur.parentElement
|
|
375
|
+
depth++
|
|
376
|
+
}
|
|
377
|
+
return refs
|
|
378
|
+
}
|
|
379
|
+
function stateFlagsFor(el, tag) {
|
|
380
|
+
const flags = {}
|
|
381
|
+
// disabled: prefer the property (more reliable than the attr
|
|
382
|
+
// for inputs / buttons; aria-disabled covers role=button divs).
|
|
383
|
+
if (el.disabled === true || el.getAttribute("aria-disabled") === "true") flags.disabled = true
|
|
384
|
+
if (el.checked === true) flags.checked = true
|
|
385
|
+
else if (el.indeterminate === true) flags.checked = "mixed"
|
|
386
|
+
else if (el.getAttribute("aria-checked") === "true") flags.checked = true
|
|
387
|
+
else if (el.getAttribute("aria-checked") === "mixed") flags.checked = "mixed"
|
|
388
|
+
const aria = (name) => el.getAttribute(name)
|
|
389
|
+
if (aria("aria-expanded") === "true") flags.expanded = true
|
|
390
|
+
else if (aria("aria-expanded") === "false") flags.expanded = false
|
|
391
|
+
if (el.selected === true || aria("aria-selected") === "true") flags.selected = true
|
|
392
|
+
if (aria("aria-pressed") === "true") flags.pressed = true
|
|
393
|
+
else if (aria("aria-pressed") === "false") flags.pressed = false
|
|
394
|
+
if (el.required === true || aria("aria-required") === "true") flags.required = true
|
|
395
|
+
if (el.readOnly === true || aria("aria-readonly") === "true") flags.readonly = true
|
|
396
|
+
if (aria("aria-invalid") === "true") flags.invalid = true
|
|
397
|
+
if (document.activeElement === el) flags.focused = true
|
|
398
|
+
// hidden: aria-hidden takes precedence; offsetParent === null
|
|
399
|
+
// covers display:none parents (NOT a reliable visibility check
|
|
400
|
+
// for fixed-position elements but a reasonable cheap signal).
|
|
401
|
+
if (aria("aria-hidden") === "true") flags.hidden = true
|
|
402
|
+
else if (tag !== "body" && el.offsetParent === null && getComputedStyle(el).position !== "fixed") {
|
|
403
|
+
flags.hidden = true
|
|
404
|
+
}
|
|
405
|
+
return flags
|
|
406
|
+
}
|
|
407
|
+
function inputExtrasFor(el, tag) {
|
|
408
|
+
const out = {}
|
|
409
|
+
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
410
|
+
const t = (el.type || "").toLowerCase()
|
|
411
|
+
if (t) out.inputType = t
|
|
412
|
+
}
|
|
413
|
+
const ph = el.placeholder || el.getAttribute("placeholder")
|
|
414
|
+
if (ph) out.placeholder = String(ph).slice(0, 200)
|
|
415
|
+
const ac = el.getAttribute("autocomplete")
|
|
416
|
+
if (ac) out.autocomplete = ac
|
|
417
|
+
// For inputs / textareas / select, value is the current user
|
|
418
|
+
// input. Bounded so a huge textarea doesn't bloat the snapshot.
|
|
419
|
+
if (typeof el.value === "string" && el.value.length > 0) {
|
|
420
|
+
out.value = el.value.slice(0, 200)
|
|
421
|
+
}
|
|
422
|
+
return out
|
|
423
|
+
}
|
|
424
|
+
function attrExtrasFor(el) {
|
|
425
|
+
// Surface raw attrs the matcher's L5 testid layer + L7 semantic
|
|
426
|
+
// heuristic want to see. Limited to a handful — we don't want
|
|
427
|
+
// to dump every attribute on every element.
|
|
428
|
+
const out = {}
|
|
429
|
+
const id = el.id
|
|
430
|
+
if (id) out.id = id
|
|
431
|
+
const testid = el.getAttribute("data-testid") || el.getAttribute("data-test-id")
|
|
432
|
+
|| el.getAttribute("data-test") || el.getAttribute("data-qa")
|
|
433
|
+
if (testid) out.testid = testid
|
|
434
|
+
const nameAttr = el.getAttribute("name")
|
|
435
|
+
if (nameAttr) out.name_attr = nameAttr
|
|
436
|
+
const aria = el.getAttribute("aria-label")
|
|
437
|
+
if (aria) out.aria_label = aria
|
|
438
|
+
return out
|
|
439
|
+
}
|
|
306
440
|
const elements = []
|
|
307
441
|
for (const el of interactive) {
|
|
308
442
|
if (elements.length >= ELEMENT_CAP) break
|
|
@@ -319,6 +453,7 @@ async function toolReadPage(args) {
|
|
|
319
453
|
const entry = {
|
|
320
454
|
ref,
|
|
321
455
|
role: el.getAttribute("role") || tag,
|
|
456
|
+
tag,
|
|
322
457
|
bbox: [
|
|
323
458
|
Math.round(rect.x),
|
|
324
459
|
Math.round(rect.y),
|
|
@@ -327,6 +462,25 @@ async function toolReadPage(args) {
|
|
|
327
462
|
],
|
|
328
463
|
}
|
|
329
464
|
if (name) entry.name = name
|
|
465
|
+
// Inline state flags onto the entry. Each is omitted when
|
|
466
|
+
// false / default per the snapshot-types contract.
|
|
467
|
+
const flags = stateFlagsFor(el, tag)
|
|
468
|
+
for (const k of Object.keys(flags)) entry[k] = flags[k]
|
|
469
|
+
// Input-shaped extras (placeholder / inputType / value /
|
|
470
|
+
// autocomplete) — only present for input-shaped elements.
|
|
471
|
+
const inExtras = inputExtrasFor(el, tag)
|
|
472
|
+
if (inExtras.inputType) entry.inputType = inExtras.inputType
|
|
473
|
+
if (inExtras.placeholder) entry.placeholder = inExtras.placeholder
|
|
474
|
+
if (inExtras.autocomplete) entry.autocomplete = inExtras.autocomplete
|
|
475
|
+
if (inExtras.value) entry.value = inExtras.value
|
|
476
|
+
// Raw attribute extras for L5 testid + L7 semantic layers.
|
|
477
|
+
// Stored on a single `attrs` object to keep the top-level
|
|
478
|
+
// shape stable.
|
|
479
|
+
const attrExtras = attrExtrasFor(el)
|
|
480
|
+
if (Object.keys(attrExtras).length > 0) entry.attrs = attrExtras
|
|
481
|
+
// Landmark ancestry — up to 4 deep, dialog / form / nav / etc.
|
|
482
|
+
const landmarks = landmarkRefsFor(el)
|
|
483
|
+
if (landmarks.length > 0) entry.landmarks = landmarks
|
|
330
484
|
elements.push(entry)
|
|
331
485
|
}
|
|
332
486
|
// Text extraction.
|
|
@@ -397,7 +551,14 @@ async function toolReadPage(args) {
|
|
|
397
551
|
],
|
|
398
552
|
})
|
|
399
553
|
}
|
|
400
|
-
const out = {
|
|
554
|
+
const out = {
|
|
555
|
+
mode,
|
|
556
|
+
url: window.location.href,
|
|
557
|
+
title: document.title,
|
|
558
|
+
text,
|
|
559
|
+
elements,
|
|
560
|
+
viewport,
|
|
561
|
+
}
|
|
401
562
|
if (visualSurfaces.length > 0) out.visualSurfaces = visualSurfaces
|
|
402
563
|
return out
|
|
403
564
|
},
|
|
@@ -1535,9 +1696,29 @@ async function attachDebuggerOnce(tabId, opts) {
|
|
|
1535
1696
|
networkBuffers.set(tabId, [])
|
|
1536
1697
|
await chrome.debugger.sendCommand({ tabId }, "Network.enable")
|
|
1537
1698
|
}
|
|
1699
|
+
// CDP a11y-tree extraction needs DOM + Page + Accessibility
|
|
1700
|
+
// domains enabled. We track them in a single Set so a second
|
|
1701
|
+
// captureSnapshot call on the same tab is a no-op.
|
|
1702
|
+
if (opts?.accessibility && !axDomainsEnabledTabs.has(tabId)) {
|
|
1703
|
+
axDomainsEnabledTabs.add(tabId)
|
|
1704
|
+
try {
|
|
1705
|
+
await chrome.debugger.sendCommand({ tabId }, "DOM.enable")
|
|
1706
|
+
await chrome.debugger.sendCommand({ tabId }, "Page.enable")
|
|
1707
|
+
await chrome.debugger.sendCommand({ tabId }, "Accessibility.enable")
|
|
1708
|
+
} catch (err) {
|
|
1709
|
+
// Roll back the tracking flag so the next call retries.
|
|
1710
|
+
axDomainsEnabledTabs.delete(tabId)
|
|
1711
|
+
throw err
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1538
1714
|
})
|
|
1539
1715
|
}
|
|
1540
1716
|
|
|
1717
|
+
// Track which tabs have the CDP a11y domains enabled (DOM + Page +
|
|
1718
|
+
// Accessibility). Cleared on debugger.onDetach / tabs.onRemoved
|
|
1719
|
+
// alongside the other per-tab state.
|
|
1720
|
+
const axDomainsEnabledTabs = new Set()
|
|
1721
|
+
|
|
1541
1722
|
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1542
1723
|
const tabId = source.tabId
|
|
1543
1724
|
if (typeof tabId !== "number") return
|
|
@@ -1569,6 +1750,12 @@ chrome.debugger.onDetach.addListener((source) => {
|
|
|
1569
1750
|
consoleBuffers.delete(source.tabId)
|
|
1570
1751
|
networkBuffers.delete(source.tabId)
|
|
1571
1752
|
tabInputLockTails.delete(source.tabId)
|
|
1753
|
+
axDomainsEnabledTabs.delete(source.tabId)
|
|
1754
|
+
// Snapshot cache: CDP-written refs survive a detach (they're DOM
|
|
1755
|
+
// attributes, not CDP state), but bbox/AXNode IDs become unreliable
|
|
1756
|
+
// because re-attach needs a fresh DOM.enable handshake. Safer to
|
|
1757
|
+
// invalidate and re-capture on next read.
|
|
1758
|
+
invalidateSnapshot(source.tabId, "debugger-detach")
|
|
1572
1759
|
}
|
|
1573
1760
|
})
|
|
1574
1761
|
|
|
@@ -1583,6 +1770,22 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
|
|
1583
1770
|
consoleBuffers.delete(tabId)
|
|
1584
1771
|
networkBuffers.delete(tabId)
|
|
1585
1772
|
tabInputLockTails.delete(tabId)
|
|
1773
|
+
axDomainsEnabledTabs.delete(tabId)
|
|
1774
|
+
invalidateSnapshot(tabId, "tab-closed")
|
|
1775
|
+
})
|
|
1776
|
+
|
|
1777
|
+
// Snapshot cache invalidation on top-frame navigation. The legacy ref
|
|
1778
|
+
// scheme (data-gh-router-ref DOM attribute) does NOT survive a fresh
|
|
1779
|
+
// document load, so a stale snapshot would return refs that resolve
|
|
1780
|
+
// to nothing. Invalidate so the next read captures the new document.
|
|
1781
|
+
// We intentionally do NOT invalidate on child-frame navigations — a
|
|
1782
|
+
// click inside an iframe shouldn't bust the whole-tab snapshot. Phase
|
|
1783
|
+
// 1b-CDP will revisit this when cross-origin iframe ref attribution
|
|
1784
|
+
// changes the trade-off.
|
|
1785
|
+
chrome.webNavigation.onCommitted.addListener((details) => {
|
|
1786
|
+
if (details.frameId === 0 && typeof details.tabId === "number") {
|
|
1787
|
+
invalidateSnapshot(details.tabId, "navigation")
|
|
1788
|
+
}
|
|
1586
1789
|
})
|
|
1587
1790
|
|
|
1588
1791
|
async function toolConsoleLogs(args) {
|
|
@@ -1608,6 +1811,128 @@ async function toolNetworkLog(args) {
|
|
|
1608
1811
|
return { entries: drained }
|
|
1609
1812
|
}
|
|
1610
1813
|
|
|
1814
|
+
// ---------------------------------------------------------------------
|
|
1815
|
+
// Bot-challenge detection (Phase 4 auto-detect)
|
|
1816
|
+
// ---------------------------------------------------------------------
|
|
1817
|
+
// Listens to chrome.webRequest.onHeadersReceived for response-header
|
|
1818
|
+
// fingerprints of major bot-protection vendors. On match: post a
|
|
1819
|
+
// `__botDetected__` control frame to the bridge. The bridge tracks
|
|
1820
|
+
// which tabs are flagged and the proxy dispatcher consults that state
|
|
1821
|
+
// via /health to inject humanlike pacing for paced tabs.
|
|
1822
|
+
//
|
|
1823
|
+
// Signature confidence tiers:
|
|
1824
|
+
// HIGH (per-vendor, single-hit enables): cf-ray + 403/503, x-dd-b,
|
|
1825
|
+
// x-px-block, x-px-uuid, x-incapsula header on 403.
|
|
1826
|
+
// MEDIUM (cookie / generic — deferred to v2): _abck=*~-1~ cookie,
|
|
1827
|
+
// burst of 403/429 across 5 s window.
|
|
1828
|
+
//
|
|
1829
|
+
// False-positive guard: only fires when we actually own the
|
|
1830
|
+
// connection to the bridge (`nativePort` set). No phantom signals
|
|
1831
|
+
// during SW startup before the port opens.
|
|
1832
|
+
|
|
1833
|
+
const BOT_DETECTION_VENDORS = {
|
|
1834
|
+
cloudflare: (resp) => {
|
|
1835
|
+
if (resp.statusCode !== 403 && resp.statusCode !== 503) return null
|
|
1836
|
+
const cfRay = headerValue(resp.responseHeaders, "cf-ray")
|
|
1837
|
+
return cfRay ? { signal: "cf-ray + " + resp.statusCode, evidence: cfRay.slice(0, 60) } : null
|
|
1838
|
+
},
|
|
1839
|
+
datadome: (resp) => {
|
|
1840
|
+
const dd = headerValue(resp.responseHeaders, "x-dd-b")
|
|
1841
|
+
return dd === "1" ? { signal: "x-dd-b=1", evidence: "" } : null
|
|
1842
|
+
},
|
|
1843
|
+
perimeterx: (resp) => {
|
|
1844
|
+
if (headerValue(resp.responseHeaders, "x-px-block") === "1") {
|
|
1845
|
+
return { signal: "x-px-block=1", evidence: "" }
|
|
1846
|
+
}
|
|
1847
|
+
const pxUuid = headerValue(resp.responseHeaders, "x-px-uuid")
|
|
1848
|
+
if (pxUuid && (resp.statusCode === 403 || resp.statusCode === 429)) {
|
|
1849
|
+
return { signal: "x-px-uuid + " + resp.statusCode, evidence: pxUuid.slice(0, 36) }
|
|
1850
|
+
}
|
|
1851
|
+
return null
|
|
1852
|
+
},
|
|
1853
|
+
imperva: (resp) => {
|
|
1854
|
+
if (resp.statusCode !== 403) return null
|
|
1855
|
+
const iinfo = headerValue(resp.responseHeaders, "x-iinfo")
|
|
1856
|
+
return iinfo ? { signal: "x-iinfo + 403", evidence: iinfo.slice(0, 40) } : null
|
|
1857
|
+
},
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function headerValue(headers, name) {
|
|
1861
|
+
if (!Array.isArray(headers)) return undefined
|
|
1862
|
+
const lower = name.toLowerCase()
|
|
1863
|
+
for (const h of headers) {
|
|
1864
|
+
if (h.name && h.name.toLowerCase() === lower) return h.value
|
|
1865
|
+
}
|
|
1866
|
+
return undefined
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// Per-tab deduplication: a single vendor's signature firing repeatedly
|
|
1870
|
+
// on a tab should emit ONE control frame, not one per response. Bridge
|
|
1871
|
+
// already de-dupes by tabId on its side; we de-dupe here too to keep
|
|
1872
|
+
// the wire quiet.
|
|
1873
|
+
const detectedVendorsByTab = new Map() // tabId -> Set<vendor>
|
|
1874
|
+
|
|
1875
|
+
function emitBotDetected(tabId, vendor, signal, evidence) {
|
|
1876
|
+
if (typeof tabId !== "number" || tabId < 0) return
|
|
1877
|
+
if (!nativePort) return
|
|
1878
|
+
let seen = detectedVendorsByTab.get(tabId)
|
|
1879
|
+
if (!seen) {
|
|
1880
|
+
seen = new Set()
|
|
1881
|
+
detectedVendorsByTab.set(tabId, seen)
|
|
1882
|
+
}
|
|
1883
|
+
if (seen.has(vendor)) return
|
|
1884
|
+
seen.add(vendor)
|
|
1885
|
+
try {
|
|
1886
|
+
nativePort.postMessage({
|
|
1887
|
+
type: "__botDetected__",
|
|
1888
|
+
tabId,
|
|
1889
|
+
vendor,
|
|
1890
|
+
signal,
|
|
1891
|
+
evidence,
|
|
1892
|
+
ts: Date.now(),
|
|
1893
|
+
})
|
|
1894
|
+
} catch (err) {
|
|
1895
|
+
console.warn("[browser-bridge/bot-detect] post failed:", err)
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// MAIN frame only — sub-resource 403s on tracking pixels are common
|
|
1900
|
+
// noise. Vendor blocks always land on the main document request.
|
|
1901
|
+
try {
|
|
1902
|
+
chrome.webRequest.onHeadersReceived.addListener(
|
|
1903
|
+
(details) => {
|
|
1904
|
+
try {
|
|
1905
|
+
for (const [vendor, probe] of Object.entries(BOT_DETECTION_VENDORS)) {
|
|
1906
|
+
const hit = probe(details)
|
|
1907
|
+
if (hit) {
|
|
1908
|
+
emitBotDetected(details.tabId, vendor, hit.signal, hit.evidence)
|
|
1909
|
+
break
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
} catch (err) {
|
|
1913
|
+
console.warn("[browser-bridge/bot-detect] probe crashed:", err)
|
|
1914
|
+
}
|
|
1915
|
+
},
|
|
1916
|
+
{ urls: ["<all_urls>"], types: ["main_frame"] },
|
|
1917
|
+
["responseHeaders"],
|
|
1918
|
+
)
|
|
1919
|
+
} catch (err) {
|
|
1920
|
+
// webRequest permission may not be granted on some enterprise
|
|
1921
|
+
// policies; auto-detect just no-ops in that case.
|
|
1922
|
+
console.warn("[browser-bridge/bot-detect] webRequest listener registration failed:", err)
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// Cleanup: clear vendor dedup state on navigation + tab close so a
|
|
1926
|
+
// new document gets a fresh detection window.
|
|
1927
|
+
chrome.webNavigation.onCommitted.addListener((details) => {
|
|
1928
|
+
if (details.frameId === 0 && typeof details.tabId === "number") {
|
|
1929
|
+
detectedVendorsByTab.delete(details.tabId)
|
|
1930
|
+
}
|
|
1931
|
+
})
|
|
1932
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
1933
|
+
detectedVendorsByTab.delete(tabId)
|
|
1934
|
+
})
|
|
1935
|
+
|
|
1611
1936
|
const TOOL_HANDLERS = {
|
|
1612
1937
|
__ping__: () => ({
|
|
1613
1938
|
pong: true,
|
|
@@ -1725,11 +2050,43 @@ async function handleBridgeRequest(req, port) {
|
|
|
1725
2050
|
try {
|
|
1726
2051
|
const data = await handler(req.args || {})
|
|
1727
2052
|
port.postMessage({ id: req.id, ok: true, data })
|
|
2053
|
+
// Snapshot cache invalidation for mutating actions. The matcher
|
|
2054
|
+
// cascade (Phase 2) dispatches against cached snapshots; a
|
|
2055
|
+
// successful click / fill / type / etc. likely changed the page,
|
|
2056
|
+
// so the cached element list is stale. Invalidate by tabId from
|
|
2057
|
+
// the request args; tools that don't carry a tabId (open_tab on
|
|
2058
|
+
// create-path, list_tabs) are not page-mutating per-tab so they
|
|
2059
|
+
// skip this.
|
|
2060
|
+
if (MUTATES_PAGE.has(req.tool)) {
|
|
2061
|
+
const tabId = typeof req.args?.tabId === "number" ? req.args.tabId : undefined
|
|
2062
|
+
if (typeof tabId === "number") {
|
|
2063
|
+
invalidateSnapshot(tabId, `mutation:${req.tool}`)
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
1728
2066
|
} catch (err) {
|
|
1729
2067
|
port.postMessage({ id: req.id, ok: false, error: err && err.message ? err.message : String(err) })
|
|
1730
2068
|
}
|
|
1731
2069
|
}
|
|
1732
2070
|
|
|
2071
|
+
// Tools whose successful execution likely mutates the page's DOM,
|
|
2072
|
+
// triggering snapshot-cache invalidation for the tabId in args. Kept
|
|
2073
|
+
// as a Set rather than per-tool flags so adding a new mutating tool
|
|
2074
|
+
// is one line. Conservative: tools listed here MAY not mutate (e.g.
|
|
2075
|
+
// click on a disabled button is a no-op); the cost of a spurious
|
|
2076
|
+
// invalidate is one extra capture on next read, vs the cost of a
|
|
2077
|
+
// stale snapshot which is silent dispatch against a vanished ref.
|
|
2078
|
+
const MUTATES_PAGE = new Set([
|
|
2079
|
+
"browser_click",
|
|
2080
|
+
"browser_fill",
|
|
2081
|
+
"browser_type",
|
|
2082
|
+
"browser_keyboard",
|
|
2083
|
+
"browser_scroll",
|
|
2084
|
+
"browser_mouse",
|
|
2085
|
+
"browser_drag",
|
|
2086
|
+
"browser_navigate",
|
|
2087
|
+
"browser_eval_js",
|
|
2088
|
+
])
|
|
2089
|
+
|
|
1733
2090
|
chrome.runtime.onInstalled.addListener(() => {
|
|
1734
2091
|
try { connectBridge() } catch (err) { console.warn("[browser-bridge] onInstalled connect failed:", err) }
|
|
1735
2092
|
})
|
|
@@ -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.3.
|
|
5
|
+
"version": "0.3.66",
|
|
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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"scripting",
|
|
17
17
|
"downloads",
|
|
18
18
|
"webNavigation",
|
|
19
|
+
"webRequest",
|
|
19
20
|
"debugger",
|
|
20
21
|
"storage",
|
|
21
22
|
"alarms"
|