@jobshimo/browser-link 0.11.0 → 0.13.0
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/agent-instructions/content.js +29 -0
- package/dist/agent-instructions/content.js.map +1 -1
- package/dist/bridge/events.d.ts +25 -2
- package/dist/bridge/events.js +60 -1
- package/dist/bridge/events.js.map +1 -1
- package/dist/bridge/ws-bridge.d.ts +1 -1
- package/dist/bridge/ws-bridge.js +22 -1
- package/dist/bridge/ws-bridge.js.map +1 -1
- package/dist/commands/about.js +4 -2
- package/dist/commands/about.js.map +1 -1
- package/dist/extension/background.js +480 -28
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/manifest.json +3 -2
- package/dist/extension/popup.d.ts +19 -0
- package/dist/extension/popup.html +155 -0
- package/dist/extension/popup.js +93 -0
- package/dist/extension/popup.js.map +1 -1
- package/dist/messages.d.ts +25 -1
- package/dist/permissions.js +55 -1
- package/dist/permissions.js.map +1 -1
- package/dist/server.js +26 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/browser-definitions.js +186 -3
- package/dist/tools/browser-definitions.js.map +1 -1
- package/dist/tools/browser-dispatch.d.ts +9 -1
- package/dist/tools/browser-dispatch.js +147 -2
- package/dist/tools/browser-dispatch.js.map +1 -1
- package/dist/tools/server-instructions.js +37 -1
- package/dist/tools/server-instructions.js.map +1 -1
- package/package.json +1 -1
|
@@ -19,9 +19,15 @@ function isRuntimeMessage(msg) {
|
|
|
19
19
|
if (typeof msg !== 'object' || msg === null)
|
|
20
20
|
return false;
|
|
21
21
|
const m = msg;
|
|
22
|
+
if (m.action === 'versionCheck')
|
|
23
|
+
return true;
|
|
22
24
|
if (typeof m.tabId !== 'number')
|
|
23
25
|
return false;
|
|
24
|
-
return m.action === 'connect' ||
|
|
26
|
+
return (m.action === 'connect' ||
|
|
27
|
+
m.action === 'disconnect' ||
|
|
28
|
+
m.action === 'status' ||
|
|
29
|
+
m.action === 'pendingDialog' ||
|
|
30
|
+
m.action === 'respondDialog');
|
|
25
31
|
}
|
|
26
32
|
const tabStates = new Map();
|
|
27
33
|
function send(ws, msg) {
|
|
@@ -38,6 +44,43 @@ function safeParse(raw) {
|
|
|
38
44
|
function cdp(tabId, method, params = {}) {
|
|
39
45
|
return chrome.debugger.sendCommand({ tabId }, method, params);
|
|
40
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Mapping from the CDP permission name (the contract `browser.set_permission`
|
|
49
|
+
* accepts on the wire) to the `chrome.contentSettings` setting key. Only the
|
|
50
|
+
* names that actually have a contentSettings surface in MV3 are listed —
|
|
51
|
+
* `Browser.setPermission` covers more, but that domain is not reachable from
|
|
52
|
+
* `chrome.debugger` attached to a tab.
|
|
53
|
+
*/
|
|
54
|
+
const CONTENT_SETTING_BY_PERMISSION = {
|
|
55
|
+
geolocation: 'location',
|
|
56
|
+
notifications: 'notifications',
|
|
57
|
+
camera: 'camera',
|
|
58
|
+
microphone: 'microphone',
|
|
59
|
+
clipboardReadWrite: 'clipboard',
|
|
60
|
+
clipboardSanitizedWrite: 'clipboard',
|
|
61
|
+
sensors: 'sensors',
|
|
62
|
+
};
|
|
63
|
+
const STATE_TO_CONTENT_SETTING = {
|
|
64
|
+
granted: 'allow',
|
|
65
|
+
denied: 'block',
|
|
66
|
+
prompt: 'ask',
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Apply a `chrome.contentSettings.<setting>.set({ primaryPattern, setting })`
|
|
70
|
+
* call without statically referencing each key — the `contentSettings`
|
|
71
|
+
* surface is dynamic at the API level. We narrow `unknown` carefully so the
|
|
72
|
+
* cast is the smallest possible.
|
|
73
|
+
*/
|
|
74
|
+
async function applyContentSetting(settingKey, primaryPattern, setting) {
|
|
75
|
+
const root = chrome.contentSettings;
|
|
76
|
+
if (!root)
|
|
77
|
+
throw new Error('chrome.contentSettings not available in this build');
|
|
78
|
+
const setter = root[settingKey];
|
|
79
|
+
if (!setter || typeof setter.set !== 'function') {
|
|
80
|
+
throw new Error(`chrome.contentSettings.${settingKey} is not exposed in this Chrome build`);
|
|
81
|
+
}
|
|
82
|
+
await setter.set({ primaryPattern, setting });
|
|
83
|
+
}
|
|
41
84
|
/**
|
|
42
85
|
* Defensive caps applied to every duration that ends up in a setTimeout
|
|
43
86
|
* driven by tool params. The MCP request payload is technically untrusted
|
|
@@ -283,11 +326,67 @@ function attachDebuggerListener(state) {
|
|
|
283
326
|
entry.failed = p.errorText ?? 'failed';
|
|
284
327
|
return;
|
|
285
328
|
}
|
|
329
|
+
if (method === 'Page.javascriptDialogOpening') {
|
|
330
|
+
// The page JS thread is now frozen until something calls
|
|
331
|
+
// Page.handleJavaScriptDialog. We do NOT respond automatically —
|
|
332
|
+
// the agent reads dialog-opening from browser.events and decides
|
|
333
|
+
// via browser.dialog_respond. The popup also exposes accept/dismiss
|
|
334
|
+
// buttons as a manual escape hatch.
|
|
335
|
+
const p = params;
|
|
336
|
+
const info = {
|
|
337
|
+
type: p.type ?? 'alert',
|
|
338
|
+
message: p.message ?? '',
|
|
339
|
+
};
|
|
340
|
+
if (typeof p.defaultPrompt === 'string')
|
|
341
|
+
info.default_prompt = p.defaultPrompt;
|
|
342
|
+
if (typeof p.url === 'string')
|
|
343
|
+
info.url = p.url;
|
|
344
|
+
state.pendingDialog = info;
|
|
345
|
+
if (state.ws && state.ws.readyState === WebSocket.OPEN && state.serverTabId) {
|
|
346
|
+
const data = {
|
|
347
|
+
type: info.type,
|
|
348
|
+
message: info.message,
|
|
349
|
+
};
|
|
350
|
+
if (info.default_prompt !== undefined)
|
|
351
|
+
data.default_prompt = info.default_prompt;
|
|
352
|
+
if (info.url !== undefined)
|
|
353
|
+
data.url = info.url;
|
|
354
|
+
send(state.ws, {
|
|
355
|
+
kind: 'bridge.event',
|
|
356
|
+
eventKind: 'dialog-opening',
|
|
357
|
+
tabId: state.serverTabId,
|
|
358
|
+
data,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (method === 'Page.javascriptDialogClosed') {
|
|
364
|
+
const p = params;
|
|
365
|
+
state.pendingDialog = undefined;
|
|
366
|
+
if (state.ws && state.ws.readyState === WebSocket.OPEN && state.serverTabId) {
|
|
367
|
+
const data = { accept: p.result === true };
|
|
368
|
+
if (typeof p.userInput === 'string')
|
|
369
|
+
data.user_input = p.userInput;
|
|
370
|
+
send(state.ws, {
|
|
371
|
+
kind: 'bridge.event',
|
|
372
|
+
eventKind: 'dialog-closed',
|
|
373
|
+
tabId: state.serverTabId,
|
|
374
|
+
data,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
286
379
|
};
|
|
287
380
|
state.debuggerListener = listener;
|
|
288
381
|
}
|
|
289
|
-
|
|
290
|
-
|
|
382
|
+
/**
|
|
383
|
+
* Shared in-page DOM helpers, injected verbatim into any JS template that
|
|
384
|
+
* needs them (`buildSnapshotJs`, `buildFindJs`). Defining them once here
|
|
385
|
+
* keeps the heuristics (visibility, selector generation, accessible text)
|
|
386
|
+
* identical across tools so a selector returned by `snapshot` and a selector
|
|
387
|
+
* returned by `find` follow the same rules.
|
|
388
|
+
*/
|
|
389
|
+
const DOM_HELPERS_JS = `
|
|
291
390
|
function isVisible(el) {
|
|
292
391
|
if (!(el instanceof HTMLElement)) return true;
|
|
293
392
|
const style = getComputedStyle(el);
|
|
@@ -330,39 +429,168 @@ const SNAPSHOT_JS = `
|
|
|
330
429
|
}
|
|
331
430
|
return parts.join(' > ');
|
|
332
431
|
}
|
|
432
|
+
function accessibleText(el) {
|
|
433
|
+
const aria = el.getAttribute && el.getAttribute('aria-label');
|
|
434
|
+
if (aria) return aria;
|
|
435
|
+
const txt = (el.innerText || el.textContent || '').trim();
|
|
436
|
+
if (txt) return txt;
|
|
437
|
+
if ('value' in el && el.value) return String(el.value);
|
|
438
|
+
const ph = el.getAttribute && el.getAttribute('placeholder');
|
|
439
|
+
if (ph) return ph;
|
|
440
|
+
const title = el.getAttribute && el.getAttribute('title');
|
|
441
|
+
if (title) return title;
|
|
442
|
+
return '';
|
|
443
|
+
}
|
|
444
|
+
`;
|
|
445
|
+
function buildSnapshotJs(opts = {}) {
|
|
446
|
+
const optsJson = JSON.stringify({
|
|
447
|
+
withinSelector: typeof opts.within_selector === 'string' ? opts.within_selector : null,
|
|
448
|
+
onlyInteractive: opts.only_interactive === true,
|
|
449
|
+
exclude: Array.isArray(opts.exclude) ? opts.exclude : [],
|
|
450
|
+
maxInteractive: typeof opts.max_interactive === 'number' && opts.max_interactive > 0
|
|
451
|
+
? Math.min(opts.max_interactive, 500)
|
|
452
|
+
: 120,
|
|
453
|
+
});
|
|
454
|
+
return `
|
|
455
|
+
(() => {
|
|
456
|
+
${DOM_HELPERS_JS}
|
|
457
|
+
const opts = ${optsJson};
|
|
458
|
+
let root = document;
|
|
459
|
+
let notice = '';
|
|
460
|
+
if (opts.withinSelector) {
|
|
461
|
+
const sub = document.querySelector(opts.withinSelector);
|
|
462
|
+
if (!sub) {
|
|
463
|
+
return {
|
|
464
|
+
title: document.title,
|
|
465
|
+
url: location.href,
|
|
466
|
+
interactive: [],
|
|
467
|
+
notice: 'within_selector did not match any element',
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
root = sub;
|
|
471
|
+
}
|
|
472
|
+
const excludeSet = new Set((opts.exclude || []).map(s => String(s).toLowerCase()));
|
|
473
|
+
function inExcludedLandmark(el) {
|
|
474
|
+
if (excludeSet.size === 0) return false;
|
|
475
|
+
let cur = el.parentElement;
|
|
476
|
+
while (cur && cur !== document.body) {
|
|
477
|
+
const tag = cur.tagName ? cur.tagName.toLowerCase() : '';
|
|
478
|
+
if (excludeSet.has(tag)) return true;
|
|
479
|
+
if (cur === root) return false;
|
|
480
|
+
cur = cur.parentElement;
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
333
484
|
const sel = 'a[href], button, input, select, textarea, [role=button], [role=link], [role=checkbox], [role=tab], [role=menuitem], [contenteditable=true]';
|
|
334
485
|
const interactive = [];
|
|
335
|
-
|
|
486
|
+
const scope = (root.querySelectorAll ? root : document);
|
|
487
|
+
scope.querySelectorAll(sel).forEach((el) => {
|
|
336
488
|
if (!isVisible(el)) return;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
489
|
+
if (inExcludedLandmark(el)) return;
|
|
490
|
+
const tag = el.tagName.toLowerCase();
|
|
491
|
+
const role = el.getAttribute('role') || tag;
|
|
492
|
+
const entry = { tag, role, selector: genSelector(el) };
|
|
493
|
+
const txt = shortText(el);
|
|
494
|
+
if (txt) entry.text = txt;
|
|
495
|
+
if ('value' in el && el.value) entry.value = String(el.value);
|
|
496
|
+
const placeholder = el.getAttribute('placeholder');
|
|
497
|
+
if (placeholder) entry.placeholder = placeholder;
|
|
498
|
+
const aria_label = el.getAttribute('aria-label');
|
|
499
|
+
if (aria_label) entry.aria_label = aria_label;
|
|
500
|
+
const name = el.getAttribute('name');
|
|
501
|
+
if (name) entry.name = name;
|
|
502
|
+
const type = el.getAttribute('type');
|
|
503
|
+
if (type) entry.type = type;
|
|
504
|
+
const href = el.getAttribute('href');
|
|
505
|
+
if (href) entry.href = href;
|
|
506
|
+
if ('disabled' in el && el.disabled) entry.disabled = true;
|
|
507
|
+
interactive.push(entry);
|
|
355
508
|
});
|
|
356
|
-
const
|
|
357
|
-
return {
|
|
509
|
+
const result = {
|
|
358
510
|
title: document.title,
|
|
359
511
|
url: location.href,
|
|
360
|
-
|
|
361
|
-
text: visibleText,
|
|
362
|
-
interactive: interactive.slice(0, 120),
|
|
512
|
+
interactive: interactive.slice(0, opts.maxInteractive),
|
|
363
513
|
};
|
|
514
|
+
if (!opts.onlyInteractive) {
|
|
515
|
+
const headings = [];
|
|
516
|
+
scope.querySelectorAll('h1, h2, h3').forEach((h) => {
|
|
517
|
+
if (!isVisible(h)) return;
|
|
518
|
+
if (inExcludedLandmark(h)) return;
|
|
519
|
+
const t = shortText(h);
|
|
520
|
+
if (t) headings.push({ level: h.tagName, text: t });
|
|
521
|
+
});
|
|
522
|
+
result.headings = headings.slice(0, 30);
|
|
523
|
+
const textRoot = (root === document) ? (document.body || document) : root;
|
|
524
|
+
const visibleText = (textRoot && textRoot.innerText) ? textRoot.innerText.slice(0, 4000) : '';
|
|
525
|
+
if (visibleText) result.text = visibleText;
|
|
526
|
+
}
|
|
527
|
+
if (notice) result.notice = notice;
|
|
528
|
+
return result;
|
|
364
529
|
})()
|
|
365
530
|
`;
|
|
531
|
+
}
|
|
532
|
+
function buildFindJs(opts) {
|
|
533
|
+
const optsJson = JSON.stringify({
|
|
534
|
+
text: opts.text,
|
|
535
|
+
role: typeof opts.role === 'string' && opts.role.length > 0 ? opts.role : null,
|
|
536
|
+
exact: opts.exact === true,
|
|
537
|
+
candidateLimit: 5,
|
|
538
|
+
});
|
|
539
|
+
return `
|
|
540
|
+
(() => {
|
|
541
|
+
${DOM_HELPERS_JS}
|
|
542
|
+
const opts = ${optsJson};
|
|
543
|
+
const needle = opts.text.toLowerCase();
|
|
544
|
+
const ROLE_SELECTORS = {
|
|
545
|
+
button: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
|
|
546
|
+
link: 'a[href], [role="link"]',
|
|
547
|
+
textbox: 'input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="url"], input[type="tel"], input:not([type]), textarea, [role="textbox"], [contenteditable="true"]',
|
|
548
|
+
checkbox: 'input[type="checkbox"], [role="checkbox"]',
|
|
549
|
+
tab: '[role="tab"]',
|
|
550
|
+
menuitem: '[role="menuitem"]',
|
|
551
|
+
};
|
|
552
|
+
const selectorSet = opts.role && ROLE_SELECTORS[opts.role]
|
|
553
|
+
? ROLE_SELECTORS[opts.role]
|
|
554
|
+
: 'button, a, input, textarea, select, [role], [onclick], [contenteditable="true"], [tabindex]';
|
|
555
|
+
const all = Array.from(document.querySelectorAll(selectorSet));
|
|
556
|
+
const matches = [];
|
|
557
|
+
for (const el of all) {
|
|
558
|
+
if (!isVisible(el)) continue;
|
|
559
|
+
const text = accessibleText(el).trim();
|
|
560
|
+
if (text.length === 0) continue;
|
|
561
|
+
const lower = text.toLowerCase();
|
|
562
|
+
const ok = opts.exact ? lower === needle : lower.includes(needle);
|
|
563
|
+
if (ok) matches.push(el);
|
|
564
|
+
}
|
|
565
|
+
if (matches.length === 0) {
|
|
566
|
+
return { matched: false, reason: 'not-found' };
|
|
567
|
+
}
|
|
568
|
+
if (matches.length > 1) {
|
|
569
|
+
return {
|
|
570
|
+
matched: false,
|
|
571
|
+
reason: 'multiple-matches',
|
|
572
|
+
candidates: matches.slice(0, opts.candidateLimit).map((el) => ({
|
|
573
|
+
selector: genSelector(el),
|
|
574
|
+
text: shortText(el),
|
|
575
|
+
tag: el.tagName.toLowerCase(),
|
|
576
|
+
})),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const el = matches[0];
|
|
580
|
+
const rect = el.getBoundingClientRect();
|
|
581
|
+
return {
|
|
582
|
+
matched: true,
|
|
583
|
+
selector: genSelector(el),
|
|
584
|
+
coords: {
|
|
585
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
586
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
587
|
+
},
|
|
588
|
+
tag: el.tagName.toLowerCase(),
|
|
589
|
+
text: shortText(el),
|
|
590
|
+
};
|
|
591
|
+
})()
|
|
592
|
+
`;
|
|
593
|
+
}
|
|
366
594
|
async function evaluateInTab(tabId, expression) {
|
|
367
595
|
const result = await cdp(tabId, 'Runtime.evaluate', {
|
|
368
596
|
expression,
|
|
@@ -426,7 +654,31 @@ async function handleTool(state, msg) {
|
|
|
426
654
|
};
|
|
427
655
|
}
|
|
428
656
|
case 'snapshot': {
|
|
429
|
-
const value = await evaluateInTab(tabId,
|
|
657
|
+
const value = await evaluateInTab(tabId, buildSnapshotJs({
|
|
658
|
+
within_selector: optStr('within_selector'),
|
|
659
|
+
only_interactive: p.only_interactive === true,
|
|
660
|
+
exclude: Array.isArray(p.exclude)
|
|
661
|
+
? p.exclude.filter((x) => typeof x === 'string')
|
|
662
|
+
: undefined,
|
|
663
|
+
max_interactive: typeof p.max_interactive === 'number' ? p.max_interactive : undefined,
|
|
664
|
+
}));
|
|
665
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: value };
|
|
666
|
+
}
|
|
667
|
+
case 'find': {
|
|
668
|
+
const text = str('text');
|
|
669
|
+
if (!text) {
|
|
670
|
+
return {
|
|
671
|
+
kind: 'tool.response',
|
|
672
|
+
id: msg.id,
|
|
673
|
+
ok: false,
|
|
674
|
+
error: 'find: text required',
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
const value = await evaluateInTab(tabId, buildFindJs({
|
|
678
|
+
text,
|
|
679
|
+
role: optStr('role'),
|
|
680
|
+
exact: p.exact === true,
|
|
681
|
+
}));
|
|
430
682
|
return { kind: 'tool.response', id: msg.id, ok: true, result: value };
|
|
431
683
|
}
|
|
432
684
|
case 'console': {
|
|
@@ -901,6 +1153,73 @@ async function handleTool(state, msg) {
|
|
|
901
1153
|
: { matched: false, elapsed_ms: elapsedMs, checks, reason: 'timeout' },
|
|
902
1154
|
};
|
|
903
1155
|
}
|
|
1156
|
+
case 'dialog_respond': {
|
|
1157
|
+
const accept = p.accept === true;
|
|
1158
|
+
const promptText = optStr('prompt_text');
|
|
1159
|
+
// No probing — if there is no dialog open, CDP returns an error.
|
|
1160
|
+
// We propagate it; the caller decides if "no dialog to respond to"
|
|
1161
|
+
// is a problem or a race they can ignore.
|
|
1162
|
+
const params = { accept };
|
|
1163
|
+
if (promptText !== undefined)
|
|
1164
|
+
params.promptText = promptText;
|
|
1165
|
+
await cdp(tabId, 'Page.handleJavaScriptDialog', params);
|
|
1166
|
+
return {
|
|
1167
|
+
kind: 'tool.response',
|
|
1168
|
+
id: msg.id,
|
|
1169
|
+
ok: true,
|
|
1170
|
+
result: { accepted: accept },
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
case 'set_permission': {
|
|
1174
|
+
const origin = str('origin');
|
|
1175
|
+
const name = str('name');
|
|
1176
|
+
const state_ = str('state');
|
|
1177
|
+
// CDP `Browser.setPermission` requires a browser-level target,
|
|
1178
|
+
// which `chrome.debugger.attach({ tabId })` does NOT give us in
|
|
1179
|
+
// MV3. We use `chrome.contentSettings` instead — that surface IS
|
|
1180
|
+
// available to extensions and covers the realistic permissions
|
|
1181
|
+
// an agent needs to pre-set (geolocation, notifications, camera,
|
|
1182
|
+
// microphone, clipboard, sensors).
|
|
1183
|
+
const settingKey = CONTENT_SETTING_BY_PERMISSION[name];
|
|
1184
|
+
if (!settingKey) {
|
|
1185
|
+
return {
|
|
1186
|
+
kind: 'tool.response',
|
|
1187
|
+
id: msg.id,
|
|
1188
|
+
ok: false,
|
|
1189
|
+
error: `set_permission: '${name}' is not supported by chrome.contentSettings in MV3. Supported names: ${Object.keys(CONTENT_SETTING_BY_PERMISSION).join(', ')}.`,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
const setting = STATE_TO_CONTENT_SETTING[state_];
|
|
1193
|
+
if (!setting) {
|
|
1194
|
+
return {
|
|
1195
|
+
kind: 'tool.response',
|
|
1196
|
+
id: msg.id,
|
|
1197
|
+
ok: false,
|
|
1198
|
+
error: `set_permission: unknown state '${state_}' (expected granted | denied | prompt).`,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
// `chrome.contentSettings.<name>.set({ primaryPattern, setting })`
|
|
1202
|
+
// requires a URL pattern, not a bare origin. Append `/*` so the
|
|
1203
|
+
// pattern covers every path on the origin.
|
|
1204
|
+
const primaryPattern = origin.endsWith('/*') ? origin : `${origin}/*`;
|
|
1205
|
+
try {
|
|
1206
|
+
await applyContentSetting(settingKey, primaryPattern, setting);
|
|
1207
|
+
}
|
|
1208
|
+
catch (err) {
|
|
1209
|
+
return {
|
|
1210
|
+
kind: 'tool.response',
|
|
1211
|
+
id: msg.id,
|
|
1212
|
+
ok: false,
|
|
1213
|
+
error: `set_permission: ${err instanceof Error ? err.message : String(err)}`,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
return {
|
|
1217
|
+
kind: 'tool.response',
|
|
1218
|
+
id: msg.id,
|
|
1219
|
+
ok: true,
|
|
1220
|
+
result: { origin, name, state: state_, applied_as: settingKey },
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
904
1223
|
default:
|
|
905
1224
|
return { kind: 'tool.response', id: msg.id, ok: false, error: `Unknown tool: ${msg.tool}` };
|
|
906
1225
|
}
|
|
@@ -1050,6 +1369,11 @@ async function connectTab(tabId) {
|
|
|
1050
1369
|
return;
|
|
1051
1370
|
if (msg.kind === 'tab.registered') {
|
|
1052
1371
|
state.serverTabId = msg.payload.tabId;
|
|
1372
|
+
// serverVersion is optional on the wire so older servers that
|
|
1373
|
+
// predate the field still parse — narrow on `typeof string`.
|
|
1374
|
+
if (typeof msg.payload.serverVersion === 'string') {
|
|
1375
|
+
state.serverVersion = msg.payload.serverVersion;
|
|
1376
|
+
}
|
|
1053
1377
|
// Remember this id so the next reconnect (after a primary swap)
|
|
1054
1378
|
// asks the new primary to honour it. The primary emits
|
|
1055
1379
|
// `tab-renamed` in the bridge event log if it can't.
|
|
@@ -1094,6 +1418,40 @@ function getTabStatus(tabId) {
|
|
|
1094
1418
|
return { connected: false };
|
|
1095
1419
|
return { connected: true, serverTabId: state.serverTabId };
|
|
1096
1420
|
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Compute the version-mismatch summary for the popup. Picks the
|
|
1423
|
+
* `serverVersion` reported by ANY currently-connected tab — they all come
|
|
1424
|
+
* from the same primary, so the first one is enough. When no tab has
|
|
1425
|
+
* registered yet (e.g. user just installed and hasn't pressed Connect),
|
|
1426
|
+
* `server` stays null and the popup hides the banner.
|
|
1427
|
+
*/
|
|
1428
|
+
function getVersionCheck() {
|
|
1429
|
+
const extension = chrome.runtime.getManifest().version;
|
|
1430
|
+
let server = null;
|
|
1431
|
+
for (const s of tabStates.values()) {
|
|
1432
|
+
if (typeof s.serverVersion === 'string' && s.serverVersion.length > 0) {
|
|
1433
|
+
server = s.serverVersion;
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return {
|
|
1438
|
+
extension,
|
|
1439
|
+
server,
|
|
1440
|
+
aligned: server === null ? null : server === extension,
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
async function respondToDialogFromPopup(tabId, accept, promptText) {
|
|
1444
|
+
try {
|
|
1445
|
+
const params = { accept };
|
|
1446
|
+
if (promptText !== undefined)
|
|
1447
|
+
params.promptText = promptText;
|
|
1448
|
+
await cdp(tabId, 'Page.handleJavaScriptDialog', params);
|
|
1449
|
+
return { ok: true };
|
|
1450
|
+
}
|
|
1451
|
+
catch (err) {
|
|
1452
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1097
1455
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
1098
1456
|
if (!isRuntimeMessage(msg))
|
|
1099
1457
|
return false;
|
|
@@ -1105,6 +1463,19 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
1105
1463
|
void disconnectTab(msg.tabId).then(sendResponse);
|
|
1106
1464
|
return true;
|
|
1107
1465
|
}
|
|
1466
|
+
if (msg.action === 'pendingDialog') {
|
|
1467
|
+
const state = tabStates.get(msg.tabId);
|
|
1468
|
+
sendResponse({ pending: state?.pendingDialog ?? null });
|
|
1469
|
+
return false;
|
|
1470
|
+
}
|
|
1471
|
+
if (msg.action === 'respondDialog') {
|
|
1472
|
+
void respondToDialogFromPopup(msg.tabId, msg.accept, msg.promptText).then(sendResponse);
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
if (msg.action === 'versionCheck') {
|
|
1476
|
+
sendResponse(getVersionCheck());
|
|
1477
|
+
return false;
|
|
1478
|
+
}
|
|
1108
1479
|
// 'status'
|
|
1109
1480
|
sendResponse(getTabStatus(msg.tabId));
|
|
1110
1481
|
return false;
|
|
@@ -1116,6 +1487,87 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
|
|
1116
1487
|
// The Chrome tab is gone — drop any cached browser-link id for it.
|
|
1117
1488
|
void forgetTabId(tabId);
|
|
1118
1489
|
});
|
|
1490
|
+
/* Auto-connect tabs spawned by a connected tab.
|
|
1491
|
+
*
|
|
1492
|
+
* When a tab the user has connected via the popup opens a new tab
|
|
1493
|
+
* (window.open, target="_blank", a click handler doing window.open, etc.),
|
|
1494
|
+
* Chrome reports it via tabs.onCreated with `openerTabId` pointing at the
|
|
1495
|
+
* source. We use that link to:
|
|
1496
|
+
* 1. Wait for the new tab to settle on a real URL (the first onCreated
|
|
1497
|
+
* callback often reports `about:blank` even when a destination is set).
|
|
1498
|
+
* 2. Auto-connect the new tab through the same flow as the popup
|
|
1499
|
+
* "Connect" button — attach debugger, register with the server.
|
|
1500
|
+
* 3. Emit a `tab-created` bridge.event tagged with `opened_from = the
|
|
1501
|
+
* opener's server tab id`. The MCP `browser.wait_for_tab` tool reads
|
|
1502
|
+
* that event and auto-claims the new tab for the waiting agent.
|
|
1503
|
+
*
|
|
1504
|
+
* If the opener is not connected (regular browsing), we do nothing —
|
|
1505
|
+
* background tabs the user opens for themselves stay out of the bridge.
|
|
1506
|
+
*/
|
|
1507
|
+
chrome.tabs.onCreated.addListener((tab) => {
|
|
1508
|
+
const newTabId = tab.id;
|
|
1509
|
+
const openerTabId = tab.openerTabId;
|
|
1510
|
+
if (typeof newTabId !== 'number')
|
|
1511
|
+
return;
|
|
1512
|
+
if (typeof openerTabId !== 'number')
|
|
1513
|
+
return;
|
|
1514
|
+
const openerState = tabStates.get(openerTabId);
|
|
1515
|
+
if (!openerState)
|
|
1516
|
+
return;
|
|
1517
|
+
const openedFrom = openerState.serverTabId;
|
|
1518
|
+
if (!openedFrom)
|
|
1519
|
+
return;
|
|
1520
|
+
void (async () => {
|
|
1521
|
+
// Wait until chrome.tabs.get returns a real `url`. `pendingUrl` alone
|
|
1522
|
+
// is NOT enough — connectTab's guard short-circuits on `!tab.url`.
|
|
1523
|
+
// Bound at 5 s so a never-navigating tab doesn't park us forever.
|
|
1524
|
+
const deadline = Date.now() + 5_000;
|
|
1525
|
+
let ready = false;
|
|
1526
|
+
while (Date.now() < deadline) {
|
|
1527
|
+
try {
|
|
1528
|
+
const t = await chrome.tabs.get(newTabId);
|
|
1529
|
+
if (t.url && t.url.length > 0) {
|
|
1530
|
+
ready = true;
|
|
1531
|
+
break;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
await sleep(50);
|
|
1538
|
+
}
|
|
1539
|
+
if (!ready)
|
|
1540
|
+
return;
|
|
1541
|
+
try {
|
|
1542
|
+
const result = await connectTab(newTabId);
|
|
1543
|
+
if (!result.ok || !result.serverTabId)
|
|
1544
|
+
return;
|
|
1545
|
+
const newState = tabStates.get(newTabId);
|
|
1546
|
+
if (!newState?.ws || newState.ws.readyState !== WebSocket.OPEN)
|
|
1547
|
+
return;
|
|
1548
|
+
let resolvedUrl = '';
|
|
1549
|
+
try {
|
|
1550
|
+
const settled = await chrome.tabs.get(newTabId);
|
|
1551
|
+
resolvedUrl = settled.url ?? settled.pendingUrl ?? '';
|
|
1552
|
+
}
|
|
1553
|
+
catch {
|
|
1554
|
+
// Tab vanished between attach and read.
|
|
1555
|
+
}
|
|
1556
|
+
send(newState.ws, {
|
|
1557
|
+
kind: 'bridge.event',
|
|
1558
|
+
eventKind: 'tab-created',
|
|
1559
|
+
tabId: newState.serverTabId,
|
|
1560
|
+
data: {
|
|
1561
|
+
opened_from: openedFrom,
|
|
1562
|
+
url: resolvedUrl,
|
|
1563
|
+
},
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
catch {
|
|
1567
|
+
// Auto-connect is best-effort.
|
|
1568
|
+
}
|
|
1569
|
+
})();
|
|
1570
|
+
});
|
|
1119
1571
|
chrome.debugger.onDetach.addListener((source) => {
|
|
1120
1572
|
if (source.tabId === undefined)
|
|
1121
1573
|
return;
|