pawbrowse 0.6.1 → 0.6.2
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/README.md +10 -0
- package/extension/background.js +276 -46
- package/extension/manifest.json +1 -1
- package/mcp/server.mjs +29 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
<a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-server-blue.svg" alt="MCP"></a>
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
|
+
<p align="center">
|
|
23
|
+
<a href="assets/demo.mp4"><img src="assets/demo.gif" alt="A real Google Flights run at 1× speed: Zürich to London, the cheapest nonstop flight opened in 6.8 seconds, each targeted element highlighted with its ref" width="100%"></a>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<p align="center"><sub>
|
|
27
|
+
A real run on live Google Flights at <b>1× speed</b> — every frame is the original screencast, the result is verified from the page itself.
|
|
28
|
+
The plan is scripted, so this is PawBrowse's browser time only; your agent's thinking time comes on top.
|
|
29
|
+
<a href="assets/demo.mp4">MP4</a> · reproduce with <code>node scripts/demo/record.mjs rec && python3 scripts/demo/render.py rec</code>
|
|
30
|
+
</sub></p>
|
|
31
|
+
|
|
22
32
|
PawBrowse is a **Chrome MV3 extension + a tiny zero-dependency MCP server** that lets your local
|
|
23
33
|
AI coding agent (like **Claude Code**) read and act on your **actual, logged-in browser tabs** —
|
|
24
34
|
your profile, your sessions, your open pages — with **no remote-debug port, no browser relaunch,
|
package/extension/background.js
CHANGED
|
@@ -288,7 +288,9 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
288
288
|
if (method === 'Page.javascriptDialogClosed') { openDialogs.delete(tabId); return; }
|
|
289
289
|
if (method !== 'Page.javascriptDialogOpening') return;
|
|
290
290
|
const pol = acting.get(tabId);
|
|
291
|
-
|
|
291
|
+
// Dialogs from any frame (incl. out-of-process iframes) are raised on — and answered via — the
|
|
292
|
+
// root session (Chromium routes them through the main frame's Page domain).
|
|
293
|
+
const where = tabId;
|
|
292
294
|
if (!pol) { openDialogs.set(tabId, { type: params.type, message: params.message, where }); return; }
|
|
293
295
|
const accept = pol.accept != null ? pol.accept : params.type === 'alert';
|
|
294
296
|
const promptText = pol.text != null ? String(pol.text) : (params.defaultPrompt || '');
|
|
@@ -302,7 +304,16 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
302
304
|
async function whileActing(tabId, fn, policy) {
|
|
303
305
|
const mine = { ...(policy || {}) };
|
|
304
306
|
acting.set(tabId, mine);
|
|
305
|
-
try { return await fn(); } finally {
|
|
307
|
+
try { return await fn(); } finally {
|
|
308
|
+
// A dialog that pops up just AFTER the action (a follow-up alert once a confirm is answered) is
|
|
309
|
+
// still its consequence: keep answering with the default policy for a short while and report it
|
|
310
|
+
// with the next result, instead of leaving the page frozen for the next call.
|
|
311
|
+
if (acting.get(tabId) === mine) {
|
|
312
|
+
const linger = { linger: true };
|
|
313
|
+
acting.set(tabId, linger);
|
|
314
|
+
setTimeout(() => { if (acting.get(tabId) === linger) acting.delete(tabId); }, 1500);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
306
317
|
}
|
|
307
318
|
|
|
308
319
|
function takeDialogLog(tabId) {
|
|
@@ -322,6 +333,7 @@ function assertNoOpenDialog(tabId) {
|
|
|
322
333
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
323
334
|
attachedTabs.delete(tabId);
|
|
324
335
|
forgetTab(tabId, true);
|
|
336
|
+
openedBy.delete(tabId);
|
|
325
337
|
const owner = tabOwner.get(tabId);
|
|
326
338
|
tabOwner.delete(tabId);
|
|
327
339
|
if (owner != null) {
|
|
@@ -422,7 +434,11 @@ async function endSession(session) {
|
|
|
422
434
|
const MO_INSTALL = `var M=window.__pawmo; if(!M){ M=window.__pawmo={last:performance.now(),times:[],roots:new WeakSet()};
|
|
423
435
|
M.mo=new MutationObserver(function(){ var t=performance.now(); M.last=t; M.times.push(t); if(M.times.length>64) M.times.shift(); });
|
|
424
436
|
M.watch=function(r){ if(!M.roots.has(r)){ M.roots.add(r); try{ M.mo.observe(r,{subtree:true,childList:true,attributes:true,characterData:true}); }catch(_){} } };
|
|
425
|
-
M.watch(document);
|
|
437
|
+
M.watch(document);
|
|
438
|
+
// A busy main thread (parsing/running JS, rendering) is not "quiet" even when the DOM is still:
|
|
439
|
+
// SPA routes often mutate nothing for a few hundred ms and then render everything at once.
|
|
440
|
+
try{ new PerformanceObserver(function(l){ l.getEntries().forEach(function(e){ var end=e.startTime+e.duration; if(end>M.last) M.last=end; }); }).observe({type:'longtask', buffered:false}); }catch(_){}
|
|
441
|
+
}`;
|
|
426
442
|
const SNAPSHOT = `(function(seed, opts){
|
|
427
443
|
opts=opts||{};
|
|
428
444
|
try{
|
|
@@ -472,7 +488,9 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
472
488
|
// Visible descendants only: display:none / hidden children (tooltips, menus) must not leak in.
|
|
473
489
|
// A rich-text editor's text is its VALUE, never its name (else typing into it renames it and
|
|
474
490
|
// its ref is refused on the next action).
|
|
475
|
-
|
|
491
|
+
// A <slot> shows the nodes ASSIGNED to it (a web component's light-DOM label), not its children.
|
|
492
|
+
var kids = tag==='SLOT' ? ((e.assignedNodes && e.assignedNodes({flatten:true}).length) ? e.assignedNodes({flatten:true}) : e.childNodes) : e.childNodes;
|
|
493
|
+
var txt = (tag==='INPUT'||tag==='SELECT'||tag==='TEXTAREA'||e.isContentEditable) ? '' : [].map.call(kids,function(n){ return n.nodeType===3 ? n.textContent : (n.nodeType===1 && visible(n) ? name(n,seen) : ''); }).join(' ').trim();
|
|
476
494
|
if(txt) return txt;
|
|
477
495
|
return e.getAttribute('title')||e.getAttribute('placeholder')||e.getAttribute('aria-placeholder')||'';
|
|
478
496
|
}
|
|
@@ -506,7 +524,7 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
506
524
|
// Semantic controls + custom clickables: any contenteditable, an inline onclick, or a
|
|
507
525
|
// keyboard-focusable [tabindex] (framework buttons — React-Native-Web Pressables, design-system
|
|
508
526
|
// divs — often expose only these). cursor:pointer clickables are added separately in collect().
|
|
509
|
-
var selector='a[href],button,input,textarea,select,summary,[contenteditable]:not([contenteditable="false"]),[onclick],[tabindex]:not([tabindex="-1"]),[draggable="true"],'+roles.map(function(r){return '[role="'+r+'"]';}).join(',');
|
|
527
|
+
var selector='a[href],button,input,textarea,select,summary,[contenteditable]:not([contenteditable="false"]),[onclick],[onmousedown],[onmouseup],[onpointerdown],[onpointerup],[ondblclick],[tabindex]:not([tabindex="-1"]),[draggable="true"],'+roles.map(function(r){return '[role="'+r+'"]';}).join(',');
|
|
510
528
|
// Inputs whose value is SET (not typed): typing into these is unreliable, so type() routes them
|
|
511
529
|
// through a value setter. The hint tells the agent the expected format.
|
|
512
530
|
var SETTABLE={date:'YYYY-MM-DD',time:'HH:MM','datetime-local':'YYYY-MM-DDTHH:MM',month:'YYYY-MM',week:'YYYY-Www',color:'#rrggbb',range:''};
|
|
@@ -529,13 +547,47 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
529
547
|
}
|
|
530
548
|
// Hit-test a frame-local point in the element's own root, descending into nested open shadow
|
|
531
549
|
// roots, so a covered control (overlay, modal backdrop, pointer-events:none) is flagged.
|
|
550
|
+
// Is f (what is actually under the pointer) just the target's own visible content? Common
|
|
551
|
+
// pattern (Google results, cards): the accessible element sits UNDER a sibling that renders the
|
|
552
|
+
// row. Clicking there is what a person does. Not so for an empty overlay, a modal/fixed layer, a
|
|
553
|
+
// different control, or an ancestor of the target.
|
|
554
|
+
cache.sameWidget=function(t, f){
|
|
555
|
+
try{
|
|
556
|
+
if(!f || f===t || f.contains(t)) return false;
|
|
557
|
+
var p=t.parentElement, d=0; while(p && d<2 && !p.contains(f)){ p=p.parentElement; d++; }
|
|
558
|
+
if(!p || !p.contains(f) || p.tagName==='BODY' || p.tagName==='HTML') return false;
|
|
559
|
+
for(var a=f; a && a!==p; a=a.parentElement){
|
|
560
|
+
var cs=a.ownerDocument.defaultView.getComputedStyle(a);
|
|
561
|
+
if(cs.position==='fixed' || cs.position==='sticky') return false;
|
|
562
|
+
if(a.matches('a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=menuitem],[role=option],[role=tab]')) return false;
|
|
563
|
+
}
|
|
564
|
+
// A layer that CONTAINS other controls (a promo with its own button) is a different widget.
|
|
565
|
+
if(f.querySelector('a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=menuitem],[role=option],[role=tab]')) return false;
|
|
566
|
+
return !!((f.innerText||'').trim() || f.querySelector('img,svg') || /^(IMG|SVG)$/i.test(f.tagName));
|
|
567
|
+
}catch(_){ return false; }
|
|
568
|
+
};
|
|
569
|
+
// Where to point at an element: the centre of its box — except for an inline element that WRAPS
|
|
570
|
+
// (a link across two lines), whose box centre falls between the lines, on the surrounding text.
|
|
571
|
+
// Then use the centre of its first visible line box.
|
|
572
|
+
cache.pt=function(el){
|
|
573
|
+
var r=el.getBoundingClientRect(), rs=el.getClientRects(), v=el.ownerDocument.defaultView;
|
|
574
|
+
if(rs.length>1){ for(var i=0;i<rs.length;i++){ var q=rs[i]; if(q.width>=2 && q.height>=2 && q.bottom>0 && q.top<v.innerHeight) return {x:q.x+q.width/2, y:q.y+q.height/2, r:r}; } }
|
|
575
|
+
return {x:r.x+r.width/2, y:r.y+r.height/2, r:r};
|
|
576
|
+
};
|
|
577
|
+
// Pointing at a web component's SLOTTED text (its light-DOM label) makes the shadow root's
|
|
578
|
+
// elementFromPoint answer with the host: that's the control's own content, not a cover.
|
|
579
|
+
function slotted(t, f){ var rt=t.getRootNode(); return !!(rt && rt.host && f===rt.host && t.querySelector && t.querySelector('slot')); }
|
|
580
|
+
cache.slotted=slotted;
|
|
581
|
+
// Does a pointer event landing on f count as hitting t? (t itself or inside it, its label's control,
|
|
582
|
+
// its slotted content, or its own row content.) Used before AND during the click.
|
|
583
|
+
cache.accepts=function(t, f){ return !!f && (t===f || (f.nodeType===1 && t.contains(f)) || (t.control && t.control===f) || slotted(t, f) || cache.sameWidget(t, f)); };
|
|
532
584
|
cache.hits=function(t, lx, ly){
|
|
533
585
|
try{
|
|
534
|
-
if(lx==null){ var
|
|
586
|
+
if(lx==null){ var hp=cache.pt(t); lx=hp.x; ly=hp.y; }
|
|
535
587
|
var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
|
|
536
588
|
var f=root.elementFromPoint(lx,ly), g=0;
|
|
537
589
|
while(f && sroot(f) && g++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
|
|
538
|
-
return !!f && (t===f || t.contains(f) || (t.control && t.control===f));
|
|
590
|
+
return !!f && (t===f || t.contains(f) || (t.control && t.control===f) || slotted(t, f) || cache.sameWidget(t, f));
|
|
539
591
|
}catch(_){ return true; }
|
|
540
592
|
};
|
|
541
593
|
var hits=cache.hits;
|
|
@@ -584,6 +636,8 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
584
636
|
// systems) render as role-less <div>s but get cursor:pointer. Include the ROOT of each
|
|
585
637
|
// pointer region (its parent is NOT pointer) so we capture the pressable itself, not its
|
|
586
638
|
// inherited-cursor text children. Bounded scan so huge DOMs stay fast.
|
|
639
|
+
// <a> without href but with inline JS handlers (onmouseenter/onmousedown/...): JS-driven links.
|
|
640
|
+
if(!seen.has(n) && n.tagName==='A' && !n.hasAttribute('href')){ for(var ai=0;ai<n.attributes.length;ai++){ if(/^on(mouse|pointer|touch|click|dblclick|key)/.test(n.attributes[ai].name)){ add(n, dx, dy, true); break; } } }
|
|
587
641
|
if(!seen.has(n) && scanned<8000){
|
|
588
642
|
scanned++;
|
|
589
643
|
try{
|
|
@@ -614,6 +668,23 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
614
668
|
walk(document, 0, 0, 0);
|
|
615
669
|
return out;
|
|
616
670
|
}
|
|
671
|
+
// Not hittable, but only because a sticky/fixed bar (not a modal) or something OUTSIDE its own
|
|
672
|
+
// scroll box sits on top of it — e.g. a sidebar list scrolled under the sidebar's filter header.
|
|
673
|
+
// Acting scrolls it into the open, so it's "scrolled out", not "covered".
|
|
674
|
+
function reachable(t, lx, ly){
|
|
675
|
+
try{
|
|
676
|
+
var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
|
|
677
|
+
var f=root.elementFromPoint(lx,ly); if(!f || f.contains(t)) return false;
|
|
678
|
+
var view=t.ownerDocument.defaultView;
|
|
679
|
+
for(var a=f; a && a.nodeType===1; a=a.parentElement){
|
|
680
|
+
if(a.matches('dialog,[role=dialog],[role=alertdialog],[aria-modal=true]')) return false;
|
|
681
|
+
var cs=view.getComputedStyle(a);
|
|
682
|
+
if(cs.position==='fixed' || cs.position==='sticky'){ var q=a.getBoundingClientRect(); return q.width*q.height < 0.4*view.innerWidth*view.innerHeight; }
|
|
683
|
+
}
|
|
684
|
+
var sc=t.parentElement; while(sc && !(sc.scrollHeight>sc.clientHeight+2 && /(auto|scroll)/.test(view.getComputedStyle(sc).overflowY))) sc=sc.parentElement;
|
|
685
|
+
return !!sc && sc!==t.ownerDocument.body && sc!==t.ownerDocument.documentElement && !sc.contains(f);
|
|
686
|
+
}catch(_){ return false; }
|
|
687
|
+
}
|
|
617
688
|
var scrollerCache=new Map(); // element -> does it clip its overflow? (one style read per ancestor per snapshot)
|
|
618
689
|
function clipper(a, view){ var v=scrollerCache.get(a); if(v===undefined){ var cs=view.getComputedStyle(a); v=!(cs.overflowX==='visible' && cs.overflowY==='visible'); scrollerCache.set(a,v); } return v; }
|
|
619
690
|
function clipped(t){
|
|
@@ -643,14 +714,14 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
643
714
|
// Only accept it if it has a real label and isn't just a wrapper around an actual control
|
|
644
715
|
// (or a <label> standing in for one), so we don't flood the table with layout containers.
|
|
645
716
|
var ti=e.getAttribute('tabindex');
|
|
646
|
-
if(clk || e.hasAttribute('onclick') || (ti!==null && ti!=='-1') || e.getAttribute('draggable')==='true'){
|
|
717
|
+
if(clk || e.hasAttribute('onclick') || e.hasAttribute('onmousedown') || e.hasAttribute('onmouseup') || e.hasAttribute('onpointerdown') || e.hasAttribute('onpointerup') || e.hasAttribute('ondblclick') || (ti!==null && ti!=='-1') || e.getAttribute('draggable')==='true'){
|
|
647
718
|
if(e.tagName==='LABEL' && e.control) continue;
|
|
648
719
|
if(!(name(e)||'').trim() || e.querySelector(selector)) continue;
|
|
649
720
|
rname='button';
|
|
650
721
|
}
|
|
651
722
|
}
|
|
652
723
|
if(!rname) continue;
|
|
653
|
-
var
|
|
724
|
+
var sp=cache.pt(surf), r=sp.r, lx=sp.x, ly=sp.y, x=lx+ox, y=ly+oy;
|
|
654
725
|
if(x<0||x>=VW) continue;
|
|
655
726
|
if(rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
656
727
|
var base={node:identity(e), role:rname, label:clean(fieldLabel(e)||hint(e)||rname), x:Math.round(x), y:Math.round(y), w:Math.round(r.width), h:Math.round(r.height)};
|
|
@@ -675,6 +746,8 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
675
746
|
base.off=y<0?'up':'down'; base.dist=y<0?-y:y-VH;
|
|
676
747
|
} else if((function(){ var fv=surf.ownerDocument.defaultView; return fv!==window && (lx<0||ly<0||lx>=fv.innerWidth||ly>=fv.innerHeight); })()){
|
|
677
748
|
base.off='scroll'; // scrolled out of its (same-origin) iframe's viewport
|
|
749
|
+
} else if(!hits(surf, lx, ly) && reachable(surf, lx, ly)){
|
|
750
|
+
base.off='scroll'; base.dist=1; // hidden under a sticky bar / a panel header: right here, scrolling brings it out
|
|
678
751
|
} else if(!hits(surf, lx, ly)){
|
|
679
752
|
// Not hittable: either scrolled out of an overflow container (reachable — acting scrolls it
|
|
680
753
|
// in) or genuinely covered by an overlay/modal (needs dismissing first).
|
|
@@ -714,6 +787,7 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
714
787
|
if(offView.length>OFFCAP){ offView.forEach(function(a,k){ a.ord=k; }); offView.sort(function(a,b){ return a.dist-b.dist; }); offView.splice(OFFCAP); offView.sort(function(a,b){ return a.ord-b.ord; }); }
|
|
715
788
|
var actions=inView.concat(offView);
|
|
716
789
|
// The nearest ancestor text that isn't just the label itself: which row/item a control is in.
|
|
790
|
+
cache.ctxOf=function(el, lab){ return ctxOf(el, lab); };
|
|
717
791
|
function ctxOf(el, lab){
|
|
718
792
|
for(var p=el&&el.parentElement, g=0; p && g<6 && p.tagName!=='BODY'; p=p.parentElement, g++){
|
|
719
793
|
var tx=clean(p.innerText,400); if(!tx || tx===lab) continue;
|
|
@@ -1128,6 +1202,7 @@ async function observe(tabId, opts) {
|
|
|
1128
1202
|
async function resolveHit(tabId, ref, opts) {
|
|
1129
1203
|
const forFill = opts && opts.fill ? 'true' : 'false';
|
|
1130
1204
|
const noScroll = opts && opts.noScroll ? 'true' : 'false', noHit = opts && opts.noHit ? 'true' : 'false';
|
|
1205
|
+
const TXT = opts && opts.text != null ? JSON.stringify(String(opts.text)) : 'null';
|
|
1131
1206
|
const R = JSON.stringify(String(ref));
|
|
1132
1207
|
return evaluate(tabId, `(function(){
|
|
1133
1208
|
var c=window.__pawbrowse; if(!c||!c.byId) return {error:'no snapshot yet; observe first'};
|
|
@@ -1135,38 +1210,55 @@ async function resolveHit(tabId, ref, opts) {
|
|
|
1135
1210
|
var e=c.get?c.get(${R}):null;
|
|
1136
1211
|
if(!e||!e.isConnected) return {error:'element no longer on page (observe again)'};
|
|
1137
1212
|
if(c.guard && c.guards && c.guards[${R}]!=null && c.guard(e)!==c.guards[${R}]) return {error:'element changed since observe (observe again)'};
|
|
1213
|
+
// Same node, same label — but a different ROW? Virtualized lists recycle row elements for other
|
|
1214
|
+
// items: "Delete" may now belong to someone else. The row context it was listed with must hold.
|
|
1215
|
+
var fp=c.fps&&c.fps[${R}]; if(fp && fp.ctx && c.ctxOf && c.ctxOf(e, fp.label)!==fp.ctx) return {error:'the row this control belongs to changed since observe (now in "'+c.ctxOf(e, fp.label)+'"); observe again'};
|
|
1138
1216
|
if(e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) return {error:'element is disabled'};
|
|
1139
1217
|
if(${forFill} && (e.readOnly||e.getAttribute('aria-readonly')==='true')) return {error:'field is read-only'};
|
|
1140
1218
|
if(${forFill} && !('value' in e) && !e.isContentEditable) return {error:'not an editable field (observe again)'};
|
|
1219
|
+
// Typed text that the field would reject (email/number/url/pattern/maxlength) is refused up front:
|
|
1220
|
+
// nothing is typed, instead of a value silently dropped or half-entered.
|
|
1221
|
+
if(${forFill} && ${TXT}!==null && ${TXT}!=='' && e.tagName==='INPUT'){
|
|
1222
|
+
var probe=e.cloneNode(); probe.value=${TXT};
|
|
1223
|
+
if(['email','number','url'].indexOf(e.type)>=0 && (probe.value!==${TXT} || probe.validity.typeMismatch || probe.validity.badInput)) return {error:'"'+${TXT}+'" is not a valid '+e.type+' for this field (nothing typed)'};
|
|
1224
|
+
if(e.hasAttribute('pattern') && probe.validity.patternMismatch) return {error:'the field requires a specific format ('+(e.title||e.getAttribute('pattern'))+'); "'+${TXT}+'" does not match (nothing typed)'};
|
|
1225
|
+
}
|
|
1141
1226
|
// Value-set inputs (date/time/range/color...) take the setter path, not click+type.
|
|
1142
1227
|
if(${forFill} && e.tagName==='INPUT' && ['date','time','datetime-local','month','week','color','range'].indexOf(e.type)>=0) return {set:true};
|
|
1143
1228
|
// Click the control's visible SURFACE: a styled checkbox's <label>, or the element itself.
|
|
1144
1229
|
var s=c.surface?c.surface(e):e;
|
|
1145
1230
|
if(!s) return {error:'element not visible'};
|
|
1146
|
-
if(!${noScroll}) s.scrollIntoView({block:'center',inline:'center'});
|
|
1147
|
-
var r=s.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
|
|
1148
|
-
// Frame-local center (in the element's own frame viewport)...
|
|
1149
|
-
var lx=r.x+r.width/2, ly=r.y+r.height/2;
|
|
1150
|
-
// ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
|
|
1151
|
-
var dx=0, dy=0, w=(s.ownerDocument&&s.ownerDocument.defaultView), g=0;
|
|
1152
|
-
while(w && w.frameElement && g++<12){
|
|
1153
|
-
var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
|
|
1154
|
-
dx+=fr.left+fe.clientLeft+(parseFloat(fcs.paddingLeft)||0);
|
|
1155
|
-
dy+=fr.top+fe.clientTop+(parseFloat(fcs.paddingTop)||0);
|
|
1156
|
-
w=fe.ownerDocument.defaultView;
|
|
1157
|
-
}
|
|
1158
|
-
var x=Math.round(lx+dx), y=Math.round(ly+dy);
|
|
1159
|
-
if(x<0||y<0||x>=innerWidth||y>=innerHeight) return {error:'element off-screen after scroll'};
|
|
1160
1231
|
// Hit-test in the surface's OWN root (document / shadow root / iframe doc) with frame-local
|
|
1161
|
-
// coords, descending through nested open shadow roots
|
|
1162
|
-
// aren't falsely reported as covered.
|
|
1163
|
-
if(${noHit}) return {x:x, y:y};
|
|
1164
|
-
var root=s.getRootNode(); if(!root||!root.elementFromPoint) root=s.ownerDocument;
|
|
1165
|
-
var f=root.elementFromPoint(lx,ly), k=0;
|
|
1232
|
+
// coords, descending through nested open/closed shadow roots.
|
|
1166
1233
|
var sroot=function(n){ return n.shadowRoot || (c.closed && c.closed.get(n)) || null; };
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1234
|
+
var at=function(){
|
|
1235
|
+
var r=s.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
|
|
1236
|
+
var fw=s.ownerDocument.defaultView;
|
|
1237
|
+
var sp=c.pt?c.pt(s):{x:r.x+r.width/2, y:r.y+r.height/2}, lx=sp.x, ly=sp.y;
|
|
1238
|
+
// ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
|
|
1239
|
+
var dx=0, dy=0, w=fw, g=0;
|
|
1240
|
+
while(w && w.frameElement && g++<12){
|
|
1241
|
+
var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
|
|
1242
|
+
dx+=fr.left+fe.clientLeft+(parseFloat(fcs.paddingLeft)||0);
|
|
1243
|
+
dy+=fr.top+fe.clientTop+(parseFloat(fcs.paddingTop)||0);
|
|
1244
|
+
w=fe.ownerDocument.defaultView;
|
|
1245
|
+
}
|
|
1246
|
+
var inView=ly>=0 && lx>=0 && ly<fw.innerHeight && lx<fw.innerWidth && (r.height>fw.innerHeight || (r.top>=0 && r.bottom<=fw.innerHeight));
|
|
1247
|
+
var x=Math.round(lx+dx), y=Math.round(ly+dy);
|
|
1248
|
+
var root=s.getRootNode(); if(!root||!root.elementFromPoint) root=s.ownerDocument;
|
|
1249
|
+
var f=root.elementFromPoint(lx,ly), k=0;
|
|
1250
|
+
while(f && sroot(f) && k++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
|
|
1251
|
+
var hit=!!f && (s===f || s.contains(f) || (s.control && s.control===f) || (c.slotted && c.slotted(s, f)) || (c.sameWidget && c.sameWidget(s, f)));
|
|
1252
|
+
return {x:x, y:y, inView:inView && x>=0 && y>=0 && x<innerWidth && y<innerHeight, hit:hit};
|
|
1253
|
+
};
|
|
1254
|
+
// Don't move the page when the target is already on screen and hittable: needless scrolling
|
|
1255
|
+
// closes popups/date pickers and jolts the page. Otherwise bring it to the centre and re-check.
|
|
1256
|
+
var p=at(); if(p.error) return p;
|
|
1257
|
+
if(!(p.inView && p.hit) && !${noScroll}){ s.scrollIntoView({block:'center',inline:'center',behavior:'instant'}); p=at(); if(p.error) return p; }
|
|
1258
|
+
if(!p.inView && !${noHit}) return {error:'element off-screen after scroll'};
|
|
1259
|
+
if(${noHit}) return {x:p.x, y:p.y};
|
|
1260
|
+
if(!p.hit) return {error:'element is covered by another element (dismiss the overlay/dialog first)'};
|
|
1261
|
+
return {x:p.x, y:p.y};
|
|
1170
1262
|
})()`);
|
|
1171
1263
|
}
|
|
1172
1264
|
|
|
@@ -1236,7 +1328,7 @@ async function centerOfText(tabId, text) {
|
|
|
1236
1328
|
if(!pool.length) return null;
|
|
1237
1329
|
pool.sort(function(a,b){return a.area-b.area;});
|
|
1238
1330
|
var chosen=pool[0].el;
|
|
1239
|
-
chosen.scrollIntoView({block:'center',inline:'center'});
|
|
1331
|
+
chosen.scrollIntoView({block:'center',inline:'center',behavior:'instant'});
|
|
1240
1332
|
var rr=chosen.getBoundingClientRect();
|
|
1241
1333
|
var cx=Math.round(rr.left+rr.width/2), cy=Math.round(rr.top+rr.height/2);
|
|
1242
1334
|
if(cx<0||cy<0||cx>=innerWidth||cy>=innerHeight) return null;
|
|
@@ -1247,12 +1339,18 @@ async function centerOfText(tabId, text) {
|
|
|
1247
1339
|
|
|
1248
1340
|
async function clickAt(tabId, x, y, opts) {
|
|
1249
1341
|
const button = (opts && opts.button) || 'left', count = Math.max(1, Math.min(3, Number(opts && opts.count) || 1));
|
|
1250
|
-
|
|
1251
|
-
// A double/triple click is
|
|
1342
|
+
// `buttons` must say which button is held (CDP defaults it to 0; pointer-event widgets ignore a
|
|
1343
|
+
// pointerdown with no button). A double/triple click is press/release pairs with rising clickCount.
|
|
1344
|
+
// Pipelined, like Playwright: move, press and release reach the renderer together. Awaiting each
|
|
1345
|
+
// one leaves a gap in which a mousedown-triggered re-render (a blur committing a date, a ripple)
|
|
1346
|
+
// swaps the element, and Chromium then sends the click to a common ancestor — or nowhere.
|
|
1347
|
+
const held = { left: 1, right: 2, middle: 4 }[button];
|
|
1348
|
+
const sends = [sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y })];
|
|
1252
1349
|
for (let n = 1; n <= count; n++) {
|
|
1253
|
-
|
|
1254
|
-
|
|
1350
|
+
sends.push(sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, buttons: held, clickCount: n }));
|
|
1351
|
+
sends.push(sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, buttons: 0, clickCount: n }));
|
|
1255
1352
|
}
|
|
1353
|
+
await Promise.all(sends);
|
|
1256
1354
|
}
|
|
1257
1355
|
|
|
1258
1356
|
/* ------------------------------ Waiting (settle) ---------------------------- *
|
|
@@ -1263,10 +1361,11 @@ async function clickAt(tabId, x, y, opts) {
|
|
|
1263
1361
|
const watches = new Map(); // tabId -> { mainFrame, inflight: Map(reqId -> startedAt), navStart, navDone }
|
|
1264
1362
|
function tabWatch(tabId) {
|
|
1265
1363
|
let w = watches.get(tabId);
|
|
1266
|
-
if (!w) { w = { mainFrame: null, inflight: new Map(), navStart: 0, navDone: 0, committed: true, navReq: null }; watches.set(tabId, w); }
|
|
1364
|
+
if (!w) { w = { mainFrame: null, inflight: new Map(), frameLoads: new Map(), navStart: 0, navDone: 0, committed: true, navReq: null }; watches.set(tabId, w); }
|
|
1267
1365
|
return w;
|
|
1268
1366
|
}
|
|
1269
|
-
|
|
1367
|
+
// Script too: SPA route changes lazy-load code chunks and render only after they run.
|
|
1368
|
+
const TRACKED = new Set(['Fetch', 'XHR', 'Document', 'Script']);
|
|
1270
1369
|
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1271
1370
|
if (source.tabId == null) return;
|
|
1272
1371
|
const w = watches.get(source.tabId);
|
|
@@ -1278,13 +1377,18 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
1278
1377
|
if (method === 'Network.loadingFailed' && params.requestId === w.navReq && !w.committed) w.navDone = Date.now(); // blocked/aborted navigation
|
|
1279
1378
|
return;
|
|
1280
1379
|
}
|
|
1380
|
+
// Same for frames: an out-of-process iframe starts loading in the parent, stops in its own session.
|
|
1381
|
+
if (method === 'Page.frameStoppedLoading' || method === 'Page.frameDetached') { w.frameLoads.delete(params.frameId); if (source.sessionId) return; }
|
|
1281
1382
|
if (source.sessionId) return; // everything else: the top-level target only
|
|
1282
1383
|
const now = Date.now();
|
|
1283
1384
|
const begin = (loaderId) => { w.navStart = now; w.navDone = 0; w.committed = false; w.navReq = loaderId || null; w.netIdle = 0; };
|
|
1284
1385
|
switch (method) {
|
|
1285
1386
|
case 'Network.requestWillBeSent':
|
|
1286
1387
|
// Documents: only the main frame's (subframe documents finish on other sessions / may never).
|
|
1287
|
-
if (TRACKED.has(params.type) && (params.type !== 'Document' || params.frameId === w.mainFrame))
|
|
1388
|
+
if (TRACKED.has(params.type) && (params.type !== 'Document' || params.frameId === w.mainFrame)) {
|
|
1389
|
+
w.inflight.set(params.requestId, now);
|
|
1390
|
+
if (params.type === 'Script') w.lastScript = now; // a code chunk: the page is mid-transition
|
|
1391
|
+
}
|
|
1288
1392
|
if (params.type === 'Document' && params.frameId === w.mainFrame && params.requestId === params.loaderId && w.navReq !== params.requestId) {
|
|
1289
1393
|
if (w.navDone >= w.navStart) begin(params.requestId); else w.navReq = params.requestId;
|
|
1290
1394
|
}
|
|
@@ -1306,6 +1410,12 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
1306
1410
|
break;
|
|
1307
1411
|
case 'Page.frameStoppedLoading':
|
|
1308
1412
|
if (params.frameId === w.mainFrame && w.committed) w.navDone = now;
|
|
1413
|
+
else w.frameLoads.delete(params.frameId);
|
|
1414
|
+
break;
|
|
1415
|
+
case 'Page.frameStartedLoading':
|
|
1416
|
+
// An iframe (menu, widget, embed) that starts loading because of an action: its content is
|
|
1417
|
+
// part of the result, so waits follow it (capped like requests).
|
|
1418
|
+
if (params.frameId !== w.mainFrame) w.frameLoads.set(params.frameId, now);
|
|
1309
1419
|
break;
|
|
1310
1420
|
case 'Page.lifecycleEvent':
|
|
1311
1421
|
// Tied to the NEW document's loader, so the old page's late events can't end a wait.
|
|
@@ -1314,7 +1424,11 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
1314
1424
|
if (params.name === 'networkAlmostIdle' || params.name === 'networkIdle') w.netIdle = now;
|
|
1315
1425
|
}
|
|
1316
1426
|
break;
|
|
1317
|
-
case 'Page.
|
|
1427
|
+
case 'Page.navigatedWithinDocument':
|
|
1428
|
+
if (params.frameId === w.mainFrame) w.routeAt = now; // SPA route change (pushState)
|
|
1429
|
+
w.navDone = now;
|
|
1430
|
+
break;
|
|
1431
|
+
case 'Page.downloadWillBegin':
|
|
1318
1432
|
w.navDone = now;
|
|
1319
1433
|
break;
|
|
1320
1434
|
}
|
|
@@ -1324,7 +1438,13 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
1324
1438
|
const quietExpr = (since) => `(function(){ ${MO_INSTALL}
|
|
1325
1439
|
var now=performance.now(), s=${Number(since) || 0}-Date.now()+now, b={};
|
|
1326
1440
|
M.times.forEach(function(t){ if(t<s && t>=s-600) b[Math.floor((s-t)/100)]=1; });
|
|
1327
|
-
|
|
1441
|
+
// Finite CSS transitions/animations still running (a menu fading in, a panel sliding open):
|
|
1442
|
+
// until they finish, their contents may still be invisible. Infinite ones (spinners) don't count.
|
|
1443
|
+
// Those the ACTION started (startTime after it) are counted separately: they matter even on a page
|
|
1444
|
+
// that animates constantly in the background (a panel's staggered entrance on a carousel page).
|
|
1445
|
+
var anim=0, animNew=0;
|
|
1446
|
+
try{ document.getAnimations().forEach(function(a){ if(a.playState==='running' && a.effect){ var ct=a.effect.getComputedTiming(); if(isFinite(ct.endTime) && ct.endTime<=2000){ anim++; if(a.startTime!=null && a.startTime>=s-30) animNew++; } } }); }catch(_){}
|
|
1447
|
+
return [anim ? 0 : now-M.last, Object.keys(b).length>=4, animNew];
|
|
1328
1448
|
})()`;
|
|
1329
1449
|
|
|
1330
1450
|
// Network tracking only while an action is being watched (see attach()).
|
|
@@ -1392,14 +1512,27 @@ async function settle(tabId, capMs, since, opts) {
|
|
|
1392
1512
|
if (!w.inflight.has(id)) { rel.delete(id); windowEnd = Math.max(windowEnd, now + 150); continue; } // finished: allow a follow-up
|
|
1393
1513
|
if (now - w.inflight.get(id) < 8000) busy = true;
|
|
1394
1514
|
}
|
|
1515
|
+
for (const [fid, t] of w.frameLoads) {
|
|
1516
|
+
if (now - t > 8000) { w.frameLoads.delete(fid); continue; }
|
|
1517
|
+
if (t >= start - 50 && now - t < 2500) busy = true; // an embed/menu frame; ads shouldn't hold us long
|
|
1518
|
+
}
|
|
1395
1519
|
if (busy) { idleSince = 0; await sleep(30); continue; }
|
|
1396
1520
|
if (now < grace) { await sleep(30); continue; } // debounce window: a request may still be coming
|
|
1397
1521
|
if (!idleSince) idleSince = now;
|
|
1398
|
-
|
|
1399
|
-
|
|
1522
|
+
// A route change or a freshly loaded code chunk means a new view is being built: it often goes
|
|
1523
|
+
// quiet for a beat (JS executing, timers) before rendering, so ask for a longer quiet period.
|
|
1524
|
+
const transition = (w.routeAt || 0) >= start - 50 || (w.lastScript || 0) >= start - 50;
|
|
1525
|
+
const needQuiet = transition ? 250 : 60, quietCap = transition ? 1500 : 600;
|
|
1526
|
+
let quiet = 1e9, ambient = false, animNew = 0;
|
|
1527
|
+
const tq = Date.now();
|
|
1528
|
+
try { [quiet, ambient, animNew] = await evaluate(tabId, quietExpr(start)); } catch { await sleep(30); continue; } // document swapping
|
|
1529
|
+
// If even this tiny probe waited >50ms to run, the page's main thread was busy (JS/render):
|
|
1530
|
+
// not quiet, whatever the observers have reported so far.
|
|
1531
|
+
if (Date.now() - tq > 50) quiet = 0;
|
|
1400
1532
|
// DOM still changing: wait for 60ms of quiet, capped at 600ms after the network went idle, or
|
|
1401
1533
|
// 120ms on a page that was already constantly mutating (clocks, tickers, carousels) before us.
|
|
1402
|
-
if (
|
|
1534
|
+
if (animNew && now - idleSince < 1500) { await sleep(40); continue; } // the action's own animations
|
|
1535
|
+
if (quiet < needQuiet && now - idleSince < (ambient ? 120 : quietCap)) { await sleep(Math.max(10, Math.min(60, needQuiet - quiet))); continue; }
|
|
1403
1536
|
break;
|
|
1404
1537
|
}
|
|
1405
1538
|
}
|
|
@@ -1476,6 +1609,27 @@ async function pressKey(tabId, combo) {
|
|
|
1476
1609
|
return null;
|
|
1477
1610
|
}
|
|
1478
1611
|
|
|
1612
|
+
/* ------------------------- Tabs opened by an action -------------------------- *
|
|
1613
|
+
* target=_blank links and window.open() open a NEW tab: the agent would otherwise keep looking at
|
|
1614
|
+
* the old one and see "page did NOT change". A tab opened by the tab we're acting on is adopted
|
|
1615
|
+
* into the session (and its group) and becomes the one we drive. */
|
|
1616
|
+
const openedBy = new Map(); // opener tabId -> newest tab it opened during an action
|
|
1617
|
+
chrome.tabs.onCreated.addListener((t) => { if (t.openerTabId != null && acting.has(t.openerTabId)) openedBy.set(t.openerTabId, t.id); });
|
|
1618
|
+
|
|
1619
|
+
async function followNewTab(session, tabId) {
|
|
1620
|
+
const nt = openedBy.get(tabId);
|
|
1621
|
+
openedBy.delete(tabId);
|
|
1622
|
+
if (nt == null) return null;
|
|
1623
|
+
const s = sessionState(session);
|
|
1624
|
+
s.activeTabId = nt; s.createdTabs.add(nt); tabOwner.set(nt, session); persistState();
|
|
1625
|
+
await ensureGroup(s, nt);
|
|
1626
|
+
// Let it get past about:blank and load, then read it like any navigation.
|
|
1627
|
+
for (let i = 0; i < 50; i++) { try { const t = await chrome.tabs.get(nt); if (t.url && !/^about:blank/.test(t.url) && t.status === 'complete') break; } catch { return null; } await sleep(100); }
|
|
1628
|
+
await attach(nt);
|
|
1629
|
+
await settle(nt, 3000);
|
|
1630
|
+
return nt;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1479
1633
|
/* ---------------------------------- WebMCP --------------------------------- *
|
|
1480
1634
|
* Pages that implement WebMCP (navigator.modelContext.registerTool / <form toolname>) describe
|
|
1481
1635
|
* their own actions with JSON schemas. Calling one is a single deterministic step instead of a
|
|
@@ -1536,6 +1690,58 @@ async function invokeTool(tabId, name, input) {
|
|
|
1536
1690
|
return `tool ${name}: ${res.status}${res.errorText ? ` (${res.errorText})` : ''}${out ? `\n output (untrusted page data): ${out.slice(0, 2000).replace(/\n/g, '\n ')}` : ''}`;
|
|
1537
1691
|
}
|
|
1538
1692
|
|
|
1693
|
+
// Wait until the target's box stops moving (an animation, a sliding panel) before clicking it —
|
|
1694
|
+
// Playwright's "stable" check. Capped: a forever-spinning element is clicked anyway.
|
|
1695
|
+
async function waitStable(target, ref) {
|
|
1696
|
+
const R = JSON.stringify(String(ref));
|
|
1697
|
+
try {
|
|
1698
|
+
await evaluate(target, `new Promise(function(res){
|
|
1699
|
+
var c=window.__pawbrowse, e=c&&c.get&&c.get(${R}), s=e&&(c.surface?c.surface(e):e); if(!s) return res(0);
|
|
1700
|
+
var key=function(){ var r=s.getBoundingClientRect(); return [r.x,r.y,r.width,r.height].map(Math.round).join(','); };
|
|
1701
|
+
// Moving = its box changed, or a finite animation is still running on it or an ancestor (an
|
|
1702
|
+
// orbit/slide can pause at its turning points, which looks "stable" for a frame or two).
|
|
1703
|
+
var animating=function(){ try{ for(var a=s,g=0;a&&g<8;a=a.parentElement,g++){ var an=a.getAnimations?a.getAnimations():[]; for(var i=0;i<an.length;i++){ var ct=an[i].effect&&an[i].effect.getComputedTiming(); if(an[i].playState==='running' && ct && isFinite(ct.endTime)) return true; } } }catch(_){} return false; };
|
|
1704
|
+
// Fast path: nothing animating and the box unchanged over one frame -> go. Once it has been seen
|
|
1705
|
+
// moving, require two quiet frames in a row.
|
|
1706
|
+
// Nothing animating on it or its ancestors: click now (no frame wait). JS-driven motion is
|
|
1707
|
+
// still caught by the click-time hit check.
|
|
1708
|
+
if(!animating()) return res(1);
|
|
1709
|
+
var last=key(), same=0, moved=true, t0=performance.now();
|
|
1710
|
+
(function tick(){ setTimeout(function(){ var k=key(), an=animating(); if(k===last && !an){ if(++same>=(moved?2:1)) return res(1); } else { same=0; last=k; moved=true; } if(performance.now()-t0>6000) return res(0); tick(); }, 16); })();
|
|
1711
|
+
})`);
|
|
1712
|
+
} catch {}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// Click-time hit check (Playwright's hit-target interceptor): the FIRST trusted pointer/mouse event
|
|
1716
|
+
// of the click must land on the intended element (or what counts as it). If it would land on
|
|
1717
|
+
// something else — an overlay that appeared, a re-render — the whole gesture is stopped before the
|
|
1718
|
+
// wrong element sees it, and the op reports what intercepted it.
|
|
1719
|
+
async function armClick(target, ref) {
|
|
1720
|
+
const R = JSON.stringify(String(ref));
|
|
1721
|
+
return evaluate(target, `(function(){
|
|
1722
|
+
var c=window.__pawbrowse, e=c&&c.get&&c.get(${R}), s=e&&(c.surface?c.surface(e):e); if(!s||!c.accepts) return false;
|
|
1723
|
+
if(c.__arm) c.__arm.off();
|
|
1724
|
+
var st={ok:null, by:null}, types=['pointerdown','mousedown','pointerup','mouseup','click','auxclick','dblclick','contextmenu'];
|
|
1725
|
+
var hosted=function(f){ for(var r=s.getRootNode(); r && r.host; r=r.host.getRootNode()){ if(r.host===f) return true; } return false; };
|
|
1726
|
+
// The page REPLACED the target during the gesture (re-render on hover/mousedown): the new element
|
|
1727
|
+
// with the same identity (role + label) is the same control to a user.
|
|
1728
|
+
var want=c.guard?c.guard(e):'', replaced=function(f){ if(s.isConnected&&e.isConnected) return false; for(var a=f,g=0;a&&g<6;a=a.parentElement,g++){ if(want && c.guard(a)===want) return true; } return false; };
|
|
1729
|
+
var h=function(ev){
|
|
1730
|
+
if(!ev.isTrusted) return;
|
|
1731
|
+
if(st.ok===null){ var f=(ev.composedPath&&ev.composedPath()[0])||ev.target; if(f && f.nodeType!==1) f=f.parentElement;
|
|
1732
|
+
st.ok = c.accepts(s,f) || hosted(f) || (e!==s && c.accepts(e,f)) || replaced(f);
|
|
1733
|
+
if(!st.ok) st.by = f ? (f.tagName.toLowerCase()+(f.id?'#'+f.id:'')+' "'+String(f.innerText||f.getAttribute('aria-label')||'').trim().replace(/\\s+/g,' ').slice(0,40)+'"') : 'nothing'; }
|
|
1734
|
+
if(st.ok===false){ ev.preventDefault(); ev.stopImmediatePropagation(); }
|
|
1735
|
+
};
|
|
1736
|
+
types.forEach(function(t){ window.addEventListener(t, h, true); });
|
|
1737
|
+
c.__arm={st:st, off:function(){ types.forEach(function(t){ window.removeEventListener(t, h, true); }); c.__arm=null; }};
|
|
1738
|
+
return true;
|
|
1739
|
+
})()`).catch(() => false);
|
|
1740
|
+
}
|
|
1741
|
+
async function disarmClick(target) {
|
|
1742
|
+
return evaluate(target, `(function(){ var c=window.__pawbrowse, a=c&&c.__arm; if(!a) return null; var st=a.st; a.off(); return st; })()`).catch(() => null);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1539
1745
|
const dragIntercepts = new Map(); // tabId -> drag data captured by Input.dragIntercepted
|
|
1540
1746
|
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1541
1747
|
if (method === 'Input.dragIntercepted' && source.tabId != null) dragIntercepts.set(source.tabId, params.data);
|
|
@@ -1600,12 +1806,16 @@ async function runOp(tabId, op) {
|
|
|
1600
1806
|
return `dialog: ${d.type} "${String(d.message || '').slice(0, 120)}" → ${op.accept ? 'accepted' : 'dismissed'}`;
|
|
1601
1807
|
}
|
|
1602
1808
|
case 'click': {
|
|
1809
|
+
await waitStable(T, REF);
|
|
1603
1810
|
const r = await resolveHit(T, REF);
|
|
1604
1811
|
if (r.error) return `${op.ref}: ${r.error}`;
|
|
1605
1812
|
const p = await top(r.x, r.y);
|
|
1606
1813
|
if (p.covered) return `${op.ref}: element is covered by another element of the page around its frame (dismiss the overlay first)`;
|
|
1607
1814
|
const button = ['right', 'middle'].includes(op.button) ? op.button : 'left';
|
|
1815
|
+
const armed = await armClick(T, REF);
|
|
1608
1816
|
await clickAt(tabId, p.x, p.y, { button, count: op.count });
|
|
1817
|
+
const verdict = armed ? await disarmClick(T) : null;
|
|
1818
|
+
if (verdict && verdict.ok === false) return `${op.ref}: click intercepted by ${verdict.by} at the moment of the click — NOT delivered (dismiss what covers it, then retry)`;
|
|
1609
1819
|
return `${op.count > 1 ? `${op.count}x ` : ''}${button !== 'left' ? `${button}-` : ''}click ${op.ref}`;
|
|
1610
1820
|
}
|
|
1611
1821
|
case 'hover': {
|
|
@@ -1643,7 +1853,7 @@ async function runOp(tabId, op) {
|
|
|
1643
1853
|
return `click_text "${op.text}"`;
|
|
1644
1854
|
}
|
|
1645
1855
|
case 'type': {
|
|
1646
|
-
const r = await resolveHit(T, REF, { fill: true });
|
|
1856
|
+
const r = await resolveHit(T, REF, { fill: true, text: op.text ?? '' });
|
|
1647
1857
|
if (r.error) return `${op.ref}: ${r.error}`;
|
|
1648
1858
|
if (r.set) {
|
|
1649
1859
|
const sv = await setValue(T, REF, op.text);
|
|
@@ -1697,6 +1907,19 @@ async function runOp(tabId, op) {
|
|
|
1697
1907
|
const u = await uploadFiles(T, REF, paths);
|
|
1698
1908
|
return u.error ? `${op.ref}: ${u.error}` : `upload ${op.ref} (${paths.length} file${paths.length > 1 ? 's' : ''})`;
|
|
1699
1909
|
}
|
|
1910
|
+
case 'back': case 'forward': {
|
|
1911
|
+
const { currentIndex, entries } = await sendCdp(tabId, 'Page.getNavigationHistory');
|
|
1912
|
+
const to = entries[currentIndex + (op.op === 'back' ? -1 : 1)];
|
|
1913
|
+
if (!to) return `${op.op}: no ${op.op === 'back' ? 'previous' : 'next'} page in this tab's history`;
|
|
1914
|
+
const w = tabWatch(tabId); w.navStart = Date.now(); w.navDone = 0; w.committed = false; w.navReq = null;
|
|
1915
|
+
await sendCdp(tabId, 'Page.navigateToHistoryEntry', { entryId: to.id });
|
|
1916
|
+
return `${op.op} → ${String(to.url).slice(0, 100)}`;
|
|
1917
|
+
}
|
|
1918
|
+
case 'reload': {
|
|
1919
|
+
const w = tabWatch(tabId); w.navStart = Date.now(); w.navDone = 0; w.committed = false; w.navReq = null;
|
|
1920
|
+
await sendCdp(tabId, 'Page.reload', {});
|
|
1921
|
+
return 'reload';
|
|
1922
|
+
}
|
|
1700
1923
|
case 'click_xy': {
|
|
1701
1924
|
// Coordinates from the last browser_screenshot image (converted to CSS px).
|
|
1702
1925
|
const k = shotScale.get(tabId) || 1;
|
|
@@ -1857,7 +2080,9 @@ async function handleCommand(cmd, args, token, session) {
|
|
|
1857
2080
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
1858
2081
|
await attach(tabId);
|
|
1859
2082
|
assertNoOpenDialog(tabId);
|
|
1860
|
-
|
|
2083
|
+
const t = await observe(tabId, args);
|
|
2084
|
+
const dl = takeDialogLog(tabId);
|
|
2085
|
+
return dl ? `${dl}\n${t}` : t;
|
|
1861
2086
|
}
|
|
1862
2087
|
case 'read': {
|
|
1863
2088
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
@@ -1907,6 +2132,11 @@ async function handleCommand(cmd, args, token, session) {
|
|
|
1907
2132
|
// the caller think they failed and retry them.
|
|
1908
2133
|
let table;
|
|
1909
2134
|
try { table = await observe(tabId); } catch { table = null; }
|
|
2135
|
+
const nt = await followNewTab(session, tabId).catch(() => null);
|
|
2136
|
+
if (nt != null) {
|
|
2137
|
+
const t2 = await observe(nt).catch(() => '(new tab not readable yet: observe next)');
|
|
2138
|
+
return `ran ${ops.length} op(s) [page changed]:\n${logLines.join('\n')}\n${takeDialogLog(tabId)} → the page opened a NEW TAB (tab ${nt}); now driving it (the previous tab ${tabId} is left open)\n\n${t2}`;
|
|
2139
|
+
}
|
|
1910
2140
|
const changed = before == null || after == null || before !== after || table == null || seen == null || tableBody(table) !== seen || dialogLog.has(tabId);
|
|
1911
2141
|
const note = changed ? 'page changed' : 'page did NOT change (if you expected an effect, the action may not have worked — try a different target)';
|
|
1912
2142
|
if (table == null) {
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "PawBrowse",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.2",
|
|
5
5
|
"description": "Let your AI agent (Claude Code) drive your real Chrome. Fast element-table control. Open source, no keys.",
|
|
6
6
|
"minimum_chrome_version": "125",
|
|
7
7
|
"permissions": [
|
package/mcp/server.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
import crypto from 'node:crypto';
|
|
20
20
|
import { spawn } from 'node:child_process';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import fs from 'node:fs';
|
|
22
23
|
|
|
23
24
|
const posInt = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.floor(n) : d; };
|
|
24
25
|
const PORT = posInt(process.env.PAWBROWSE_PORT, 10577);
|
|
@@ -32,6 +33,26 @@ function brokerSock(port) {
|
|
|
32
33
|
const SOCK = brokerSock(PORT);
|
|
33
34
|
const BROKER_PATH = fileURLToPath(new URL('./broker.mjs', import.meta.url));
|
|
34
35
|
// A stable-ish, unique session id per server process (also names this session's tab group).
|
|
36
|
+
// Uploads: only files under these roots (default: the client's working directory, temp, Downloads,
|
|
37
|
+
// Desktop; override with PAWBROWSE_UPLOAD_ROOTS, path-list separated). Hidden files/folders
|
|
38
|
+
// (~/.ssh, .env, .aws …) are refused even inside a root — a page must never be able to talk the
|
|
39
|
+
// agent into uploading secrets.
|
|
40
|
+
const UPLOAD_ROOTS = (process.env.PAWBROWSE_UPLOAD_ROOTS
|
|
41
|
+
? process.env.PAWBROWSE_UPLOAD_ROOTS.split(path.delimiter)
|
|
42
|
+
: [process.cwd(), os.tmpdir(), path.join(os.homedir(), 'Downloads'), path.join(os.homedir(), 'Desktop')])
|
|
43
|
+
.filter(Boolean).map((r) => { try { return fs.realpathSync(r); } catch { return null; } }).filter(Boolean);
|
|
44
|
+
|
|
45
|
+
function checkUploadPath(p) {
|
|
46
|
+
if (typeof p !== 'string' || !p.trim()) throw new Error('upload: empty path');
|
|
47
|
+
let real;
|
|
48
|
+
try { real = fs.realpathSync(path.resolve(p)); } catch { throw new Error(`upload: no such file: ${p}`); }
|
|
49
|
+
if (!fs.statSync(real).isFile()) throw new Error(`upload: not a file: ${p}`);
|
|
50
|
+
const root = UPLOAD_ROOTS.find((r) => real === r || real.startsWith(r + path.sep));
|
|
51
|
+
if (!root) throw new Error(`upload: ${p} is outside the allowed folders (${UPLOAD_ROOTS.join(', ')}). Set PAWBROWSE_UPLOAD_ROOTS to allow another folder.`);
|
|
52
|
+
if (path.relative(root, real).split(path.sep).some((seg) => seg.startsWith('.'))) throw new Error(`upload: refusing hidden file or folder: ${p}`);
|
|
53
|
+
return real;
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
const SESSION = process.env.PAWBROWSE_SESSION || `s${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
|
|
36
57
|
|
|
37
58
|
const log = (...a) => process.stderr.write(`[pawbrowse] ${a.join(' ')}\n`);
|
|
@@ -172,7 +193,7 @@ const TOOLS = [
|
|
|
172
193
|
},
|
|
173
194
|
{
|
|
174
195
|
name: 'browser_act',
|
|
175
|
-
description: 'Run a list of operations on the target tab in order, then return the fresh element table — or, when the page is the same and mostly unchanged, only its new/changed rows plus the refs that are gone (refs you already hold stay valid; unchanged rows are omitted, and browser_observe returns the full table). The result says whether the page changed — if it did NOT change when you expected an effect, the action likely missed; pick a different target rather than repeating. ops: [{op:"click",ref:"e12"} (add count:2 for double-click, button:"right" for a context menu) | {op:"hover",ref:"e3"} (open hover menus/tooltips) | {op:"drag",ref:"e4",to:"e9"|to_text:"Done column"|dx:120,dy:0} (drag-and-drop, sliders, sortable lists) | {op:"click_text",text:"Built with Claude"} (click the most specific visible element matching text, for custom widgets/menus not in the table) | {op:"type",ref:"e7",text:"..."} | {op:"select",ref:"e8",value:"..."} | {op:"key",key:"Enter"} (any key or chord: "Tab", "Shift+Tab", "Escape", "PageDown", "Mod+a" = Cmd/Ctrl+A, "Control+Enter", a single character) | {op:"upload",ref:"e5",paths:["/abs/file.pdf"]} | {op:"scroll",dy:600} (add ref:"e30" to scroll the box/panel containing that control instead of the page) | {op:"tool",name:"add_to_cart",input:{...}} (call a tool the page itself offers via WebMCP — listed under "page tools" in the table; prefer it over clicking when one fits) | {op:"click_xy",x:340,y:120} (click at a point of the last browser_screenshot image — for canvas apps and things the table lacks) | {op:"wait",ms:500} | {op:"dialog",accept:true,text?:"..."} (answer an alert/confirm/prompt already open)]. JS dialogs raised by an op are answered automatically — alerts accepted, confirm/prompt DISMISSED — and reported; add dialog:"accept" (and dialog_text:"..." for a prompt) to an op to accept instead, only when the user intends it (e.g. a confirmed delete). Tips: a typed search query still needs its matching autocomplete suggestion clicked; set each requested filter explicitly (a matching-looking result alone does not prove a filter was applied); do not re-toggle a checkbox/switch/radio already in the wanted state, and do not re-type into a fill field that already shows the wanted value (the ▸ current value tells you); submit a populated search before opening a result; use wait only when the needed control is absent/disabled or results are still loading — if Submit/Search is ready, click it instead, and a recent wait is not evidence of loading.',
|
|
196
|
+
description: 'Run a list of operations on the target tab in order, then return the fresh element table — or, when the page is the same and mostly unchanged, only its new/changed rows plus the refs that are gone (refs you already hold stay valid; unchanged rows are omitted, and browser_observe returns the full table). The result says whether the page changed — if it did NOT change when you expected an effect, the action likely missed; pick a different target rather than repeating. ops: [{op:"click",ref:"e12"} (add count:2 for double-click, button:"right" for a context menu) | {op:"hover",ref:"e3"} (open hover menus/tooltips) | {op:"drag",ref:"e4",to:"e9"|to_text:"Done column"|dx:120,dy:0} (drag-and-drop, sliders, sortable lists) | {op:"click_text",text:"Built with Claude"} (click the most specific visible element matching text, for custom widgets/menus not in the table) | {op:"type",ref:"e7",text:"..."} | {op:"select",ref:"e8",value:"..."} | {op:"key",key:"Enter"} (any key or chord: "Tab", "Shift+Tab", "Escape", "PageDown", "Mod+a" = Cmd/Ctrl+A, "Control+Enter", a single character) | {op:"upload",ref:"e5",paths:["/abs/file.pdf"]} (only files under the working directory, temp, Downloads or Desktop unless PAWBROWSE_UPLOAD_ROOTS says otherwise; hidden files are always refused) | {op:"scroll",dy:600} (add ref:"e30" to scroll the box/panel containing that control instead of the page) | {op:"tool",name:"add_to_cart",input:{...}} (call a tool the page itself offers via WebMCP — listed under "page tools" in the table; prefer it over clicking when one fits) | {op:"click_xy",x:340,y:120} (click at a point of the last browser_screenshot image — for canvas apps and things the table lacks) | {op:"back"} | {op:"forward"} | {op:"reload"} | {op:"wait",ms:500} | {op:"dialog",accept:true,text?:"..."} (answer an alert/confirm/prompt already open)]. A link or script that opens a NEW TAB is followed: the result says so and shows the new tab, which becomes the one you drive. Values a field would reject (bad email/number/url, pattern mismatch) are refused before typing. JS dialogs raised by an op are answered automatically — alerts accepted, confirm/prompt DISMISSED — and reported; add dialog:"accept" (and dialog_text:"..." for a prompt) to an op to accept instead, only when the user intends it (e.g. a confirmed delete). Tips: a typed search query still needs its matching autocomplete suggestion clicked; set each requested filter explicitly (a matching-looking result alone does not prove a filter was applied); do not re-toggle a checkbox/switch/radio already in the wanted state, and do not re-type into a fill field that already shows the wanted value (the ▸ current value tells you); submit a populated search before opening a result; use wait only when the needed control is absent/disabled or results are still loading — if Submit/Search is ready, click it instead, and a recent wait is not evidence of loading.',
|
|
176
197
|
inputSchema: { type: 'object', properties: { ops: { type: 'array', items: { type: 'object' } }, tabId: { type: 'number' } }, required: ['ops'] },
|
|
177
198
|
annotations: { title: 'Act on page (click/type/select/scroll)', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
178
199
|
},
|
|
@@ -208,7 +229,13 @@ async function callTool(name, args) {
|
|
|
208
229
|
case 'browser_navigate':return textResult(await callExtension('navigate', args));
|
|
209
230
|
case 'browser_observe': return textResult(await callExtension('observe', args));
|
|
210
231
|
case 'browser_read': return textResult(await callExtension('read', args));
|
|
211
|
-
case 'browser_act':
|
|
232
|
+
case 'browser_act': {
|
|
233
|
+
// Enforce the upload policy here: the extension can't see the filesystem, the server can.
|
|
234
|
+
for (const op of (args && Array.isArray(args.ops) ? args.ops : [])) {
|
|
235
|
+
if (op && op.op === 'upload') op.paths = [].concat(op.paths ?? op.path ?? []).map(checkUploadPath);
|
|
236
|
+
}
|
|
237
|
+
return textResult(await callExtension('act', args));
|
|
238
|
+
}
|
|
212
239
|
case 'browser_assert': return textResult(await callExtension('assert', args));
|
|
213
240
|
case 'browser_screenshot': {
|
|
214
241
|
const r = await callExtension('screenshot', args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pawbrowse",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Let Claude Code drive your real, logged-in Chrome. A zero-dependency MCP server + a Chrome MV3 extension that uses element-table perception over CDP. The calling agent is the policy: no second model, no API keys, page snapshots never leave for a third party.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node mcp/server.mjs",
|
|
11
11
|
"test": "node --test --test-timeout=15000 --test-force-exit test/*.test.mjs",
|
|
12
|
-
"test:e2e": "node --test --test-timeout=
|
|
12
|
+
"test:e2e": "node --test --test-timeout=240000 --test-concurrency=1 test/e2e/*.e2e.mjs"
|
|
13
13
|
},
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|