pawbrowse 0.6.1 → 0.6.3
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 +27 -20
- package/extension/background.js +391 -50
- package/extension/manifest.json +1 -1
- package/mcp/server.mjs +43 -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,
|
|
@@ -126,30 +136,27 @@ Tips:
|
|
|
126
136
|
## Benchmark
|
|
127
137
|
|
|
128
138
|
<p align="center">
|
|
129
|
-
<img src="assets/
|
|
139
|
+
<a href="assets/benchmark.mp4"><img src="assets/benchmark.gif" alt="Side-by-side recording on live Booking.com: the same agent searches a Lisbon hotel through PawBrowse (102.5 s, 8 tool calls) and through Claude in Chrome (112.9 s, 11 tool calls)" width="100%"></a>
|
|
130
140
|
</p>
|
|
131
141
|
|
|
132
|
-
<p align="center"><
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
do **perceive-then-act** — a read (or screenshot) *then* a click — paying an extra agent round trip
|
|
137
|
-
and a larger payload every action.
|
|
138
|
-
|
|
139
|
-
Measured task: click 5 different section links on the same Wikipedia page, averaged, same machine,
|
|
140
|
-
same agent (Claude):
|
|
142
|
+
<p align="center"><sub>
|
|
143
|
+
Same task on live Booking.com (Lisbon, Oct 20–22, free cancellation, open the first hotel), same agent (Claude), same real Chrome.
|
|
144
|
+
Each run filmed from its own tab and timed from its first action; played at 8×, clocks in real time. <a href="assets/benchmark.mp4">MP4</a>
|
|
145
|
+
</sub></p>
|
|
141
146
|
|
|
142
|
-
| | PawBrowse | Claude
|
|
147
|
+
| | PawBrowse | Claude in Chrome |
|
|
143
148
|
| --- | --- | --- |
|
|
144
|
-
|
|
|
145
|
-
|
|
|
146
|
-
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
>
|
|
150
|
-
>
|
|
151
|
-
>
|
|
152
|
-
>
|
|
149
|
+
| Wall-clock (incl. the agent's thinking) | **102.5 s** | 112.9 s |
|
|
150
|
+
| Tool calls | **8** (12 actions) | 11 (19 actions) |
|
|
151
|
+
| Screenshots needed to check state | **0** — every call returns the page's fresh element table | 3 |
|
|
152
|
+
| Surprises handled | sign-in popup reported ("element is disabled") and dismissed; new tab followed automatically | a filter click that silently didn't apply, caught from a screenshot and retried |
|
|
153
|
+
|
|
154
|
+
> **Honest caveats:** most of the time on both sides is the agent thinking between calls, so the
|
|
155
|
+
> wall-clock gap is modest. The structural signal is **fewer calls and no screenshots** — PawBrowse
|
|
156
|
+
> returns the page's state with every action, so the agent never has to "look again". Run 2 (Claude in
|
|
157
|
+
> Chrome) also started with Booking remembering run 1's destination and dates. One run each; treat it
|
|
158
|
+
> as an illustration, not a statistic. Reproduce: `scripts/demo/peek-record.mjs` films a tab,
|
|
159
|
+
> `scripts/demo/render_compare.py` renders the comparison.
|
|
153
160
|
|
|
154
161
|
## How it compares
|
|
155
162
|
|
package/extension/background.js
CHANGED
|
@@ -18,7 +18,8 @@ let ws = null;
|
|
|
18
18
|
let reconnectTimer = null;
|
|
19
19
|
|
|
20
20
|
// Per-session state. Each session drives its own tab(s) inside its own tab group.
|
|
21
|
-
const attachedTabs = new Set();
|
|
21
|
+
const attachedTabs = new Set();
|
|
22
|
+
const peekOnly = new Set(); // tabs attached ONLY for dev frame capture (peek) // tabIds we currently hold a debugger on
|
|
22
23
|
const sessions = new Map(); // sessionId -> { activeTabId, createdTabs:Set, groupId, num, color }
|
|
23
24
|
const tabOwner = new Map(); // tabId -> sessionId (so sessions don't steal each other's tabs)
|
|
24
25
|
const chains = new Map();
|
|
@@ -288,7 +289,9 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
288
289
|
if (method === 'Page.javascriptDialogClosed') { openDialogs.delete(tabId); return; }
|
|
289
290
|
if (method !== 'Page.javascriptDialogOpening') return;
|
|
290
291
|
const pol = acting.get(tabId);
|
|
291
|
-
|
|
292
|
+
// Dialogs from any frame (incl. out-of-process iframes) are raised on — and answered via — the
|
|
293
|
+
// root session (Chromium routes them through the main frame's Page domain).
|
|
294
|
+
const where = tabId;
|
|
292
295
|
if (!pol) { openDialogs.set(tabId, { type: params.type, message: params.message, where }); return; }
|
|
293
296
|
const accept = pol.accept != null ? pol.accept : params.type === 'alert';
|
|
294
297
|
const promptText = pol.text != null ? String(pol.text) : (params.defaultPrompt || '');
|
|
@@ -302,7 +305,16 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
302
305
|
async function whileActing(tabId, fn, policy) {
|
|
303
306
|
const mine = { ...(policy || {}) };
|
|
304
307
|
acting.set(tabId, mine);
|
|
305
|
-
try { return await fn(); } finally {
|
|
308
|
+
try { return await fn(); } finally {
|
|
309
|
+
// A dialog that pops up just AFTER the action (a follow-up alert once a confirm is answered) is
|
|
310
|
+
// still its consequence: keep answering with the default policy for a short while and report it
|
|
311
|
+
// with the next result, instead of leaving the page frozen for the next call.
|
|
312
|
+
if (acting.get(tabId) === mine) {
|
|
313
|
+
const linger = { linger: true };
|
|
314
|
+
acting.set(tabId, linger);
|
|
315
|
+
setTimeout(() => { if (acting.get(tabId) === linger) acting.delete(tabId); }, 1500);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
306
318
|
}
|
|
307
319
|
|
|
308
320
|
function takeDialogLog(tabId) {
|
|
@@ -322,6 +334,7 @@ function assertNoOpenDialog(tabId) {
|
|
|
322
334
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
323
335
|
attachedTabs.delete(tabId);
|
|
324
336
|
forgetTab(tabId, true);
|
|
337
|
+
openedBy.delete(tabId);
|
|
325
338
|
const owner = tabOwner.get(tabId);
|
|
326
339
|
tabOwner.delete(tabId);
|
|
327
340
|
if (owner != null) {
|
|
@@ -422,7 +435,11 @@ async function endSession(session) {
|
|
|
422
435
|
const MO_INSTALL = `var M=window.__pawmo; if(!M){ M=window.__pawmo={last:performance.now(),times:[],roots:new WeakSet()};
|
|
423
436
|
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
437
|
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);
|
|
438
|
+
M.watch(document);
|
|
439
|
+
// A busy main thread (parsing/running JS, rendering) is not "quiet" even when the DOM is still:
|
|
440
|
+
// SPA routes often mutate nothing for a few hundred ms and then render everything at once.
|
|
441
|
+
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(_){}
|
|
442
|
+
}`;
|
|
426
443
|
const SNAPSHOT = `(function(seed, opts){
|
|
427
444
|
opts=opts||{};
|
|
428
445
|
try{
|
|
@@ -472,7 +489,9 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
472
489
|
// Visible descendants only: display:none / hidden children (tooltips, menus) must not leak in.
|
|
473
490
|
// A rich-text editor's text is its VALUE, never its name (else typing into it renames it and
|
|
474
491
|
// its ref is refused on the next action).
|
|
475
|
-
|
|
492
|
+
// A <slot> shows the nodes ASSIGNED to it (a web component's light-DOM label), not its children.
|
|
493
|
+
var kids = tag==='SLOT' ? ((e.assignedNodes && e.assignedNodes({flatten:true}).length) ? e.assignedNodes({flatten:true}) : e.childNodes) : e.childNodes;
|
|
494
|
+
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
495
|
if(txt) return txt;
|
|
477
496
|
return e.getAttribute('title')||e.getAttribute('placeholder')||e.getAttribute('aria-placeholder')||'';
|
|
478
497
|
}
|
|
@@ -506,7 +525,7 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
506
525
|
// Semantic controls + custom clickables: any contenteditable, an inline onclick, or a
|
|
507
526
|
// keyboard-focusable [tabindex] (framework buttons — React-Native-Web Pressables, design-system
|
|
508
527
|
// 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(',');
|
|
528
|
+
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
529
|
// Inputs whose value is SET (not typed): typing into these is unreliable, so type() routes them
|
|
511
530
|
// through a value setter. The hint tells the agent the expected format.
|
|
512
531
|
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 +548,47 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
529
548
|
}
|
|
530
549
|
// Hit-test a frame-local point in the element's own root, descending into nested open shadow
|
|
531
550
|
// roots, so a covered control (overlay, modal backdrop, pointer-events:none) is flagged.
|
|
551
|
+
// Is f (what is actually under the pointer) just the target's own visible content? Common
|
|
552
|
+
// pattern (Google results, cards): the accessible element sits UNDER a sibling that renders the
|
|
553
|
+
// row. Clicking there is what a person does. Not so for an empty overlay, a modal/fixed layer, a
|
|
554
|
+
// different control, or an ancestor of the target.
|
|
555
|
+
cache.sameWidget=function(t, f){
|
|
556
|
+
try{
|
|
557
|
+
if(!f || f===t || f.contains(t)) return false;
|
|
558
|
+
var p=t.parentElement, d=0; while(p && d<2 && !p.contains(f)){ p=p.parentElement; d++; }
|
|
559
|
+
if(!p || !p.contains(f) || p.tagName==='BODY' || p.tagName==='HTML') return false;
|
|
560
|
+
for(var a=f; a && a!==p; a=a.parentElement){
|
|
561
|
+
var cs=a.ownerDocument.defaultView.getComputedStyle(a);
|
|
562
|
+
if(cs.position==='fixed' || cs.position==='sticky') return false;
|
|
563
|
+
if(a.matches('a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=menuitem],[role=option],[role=tab]')) return false;
|
|
564
|
+
}
|
|
565
|
+
// A layer that CONTAINS other controls (a promo with its own button) is a different widget.
|
|
566
|
+
if(f.querySelector('a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=menuitem],[role=option],[role=tab]')) return false;
|
|
567
|
+
return !!((f.innerText||'').trim() || f.querySelector('img,svg') || /^(IMG|SVG)$/i.test(f.tagName));
|
|
568
|
+
}catch(_){ return false; }
|
|
569
|
+
};
|
|
570
|
+
// Where to point at an element: the centre of its box — except for an inline element that WRAPS
|
|
571
|
+
// (a link across two lines), whose box centre falls between the lines, on the surrounding text.
|
|
572
|
+
// Then use the centre of its first visible line box.
|
|
573
|
+
cache.pt=function(el){
|
|
574
|
+
var r=el.getBoundingClientRect(), rs=el.getClientRects(), v=el.ownerDocument.defaultView;
|
|
575
|
+
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}; } }
|
|
576
|
+
return {x:r.x+r.width/2, y:r.y+r.height/2, r:r};
|
|
577
|
+
};
|
|
578
|
+
// Pointing at a web component's SLOTTED text (its light-DOM label) makes the shadow root's
|
|
579
|
+
// elementFromPoint answer with the host: that's the control's own content, not a cover.
|
|
580
|
+
function slotted(t, f){ var rt=t.getRootNode(); return !!(rt && rt.host && f===rt.host && t.querySelector && t.querySelector('slot')); }
|
|
581
|
+
cache.slotted=slotted;
|
|
582
|
+
// Does a pointer event landing on f count as hitting t? (t itself or inside it, its label's control,
|
|
583
|
+
// its slotted content, or its own row content.) Used before AND during the click.
|
|
584
|
+
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
585
|
cache.hits=function(t, lx, ly){
|
|
533
586
|
try{
|
|
534
|
-
if(lx==null){ var
|
|
587
|
+
if(lx==null){ var hp=cache.pt(t); lx=hp.x; ly=hp.y; }
|
|
535
588
|
var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
|
|
536
589
|
var f=root.elementFromPoint(lx,ly), g=0;
|
|
537
590
|
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));
|
|
591
|
+
return !!f && (t===f || t.contains(f) || (t.control && t.control===f) || slotted(t, f) || cache.sameWidget(t, f));
|
|
539
592
|
}catch(_){ return true; }
|
|
540
593
|
};
|
|
541
594
|
var hits=cache.hits;
|
|
@@ -584,6 +637,8 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
584
637
|
// systems) render as role-less <div>s but get cursor:pointer. Include the ROOT of each
|
|
585
638
|
// pointer region (its parent is NOT pointer) so we capture the pressable itself, not its
|
|
586
639
|
// inherited-cursor text children. Bounded scan so huge DOMs stay fast.
|
|
640
|
+
// <a> without href but with inline JS handlers (onmouseenter/onmousedown/...): JS-driven links.
|
|
641
|
+
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
642
|
if(!seen.has(n) && scanned<8000){
|
|
588
643
|
scanned++;
|
|
589
644
|
try{
|
|
@@ -614,6 +669,23 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
614
669
|
walk(document, 0, 0, 0);
|
|
615
670
|
return out;
|
|
616
671
|
}
|
|
672
|
+
// Not hittable, but only because a sticky/fixed bar (not a modal) or something OUTSIDE its own
|
|
673
|
+
// scroll box sits on top of it — e.g. a sidebar list scrolled under the sidebar's filter header.
|
|
674
|
+
// Acting scrolls it into the open, so it's "scrolled out", not "covered".
|
|
675
|
+
function reachable(t, lx, ly){
|
|
676
|
+
try{
|
|
677
|
+
var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
|
|
678
|
+
var f=root.elementFromPoint(lx,ly); if(!f || f.contains(t)) return false;
|
|
679
|
+
var view=t.ownerDocument.defaultView;
|
|
680
|
+
for(var a=f; a && a.nodeType===1; a=a.parentElement){
|
|
681
|
+
if(a.matches('dialog,[role=dialog],[role=alertdialog],[aria-modal=true]')) return false;
|
|
682
|
+
var cs=view.getComputedStyle(a);
|
|
683
|
+
if(cs.position==='fixed' || cs.position==='sticky'){ var q=a.getBoundingClientRect(); return q.width*q.height < 0.4*view.innerWidth*view.innerHeight; }
|
|
684
|
+
}
|
|
685
|
+
var sc=t.parentElement; while(sc && !(sc.scrollHeight>sc.clientHeight+2 && /(auto|scroll)/.test(view.getComputedStyle(sc).overflowY))) sc=sc.parentElement;
|
|
686
|
+
return !!sc && sc!==t.ownerDocument.body && sc!==t.ownerDocument.documentElement && !sc.contains(f);
|
|
687
|
+
}catch(_){ return false; }
|
|
688
|
+
}
|
|
617
689
|
var scrollerCache=new Map(); // element -> does it clip its overflow? (one style read per ancestor per snapshot)
|
|
618
690
|
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
691
|
function clipped(t){
|
|
@@ -643,14 +715,14 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
643
715
|
// Only accept it if it has a real label and isn't just a wrapper around an actual control
|
|
644
716
|
// (or a <label> standing in for one), so we don't flood the table with layout containers.
|
|
645
717
|
var ti=e.getAttribute('tabindex');
|
|
646
|
-
if(clk || e.hasAttribute('onclick') || (ti!==null && ti!=='-1') || e.getAttribute('draggable')==='true'){
|
|
718
|
+
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
719
|
if(e.tagName==='LABEL' && e.control) continue;
|
|
648
720
|
if(!(name(e)||'').trim() || e.querySelector(selector)) continue;
|
|
649
721
|
rname='button';
|
|
650
722
|
}
|
|
651
723
|
}
|
|
652
724
|
if(!rname) continue;
|
|
653
|
-
var
|
|
725
|
+
var sp=cache.pt(surf), r=sp.r, lx=sp.x, ly=sp.y, x=lx+ox, y=ly+oy;
|
|
654
726
|
if(x<0||x>=VW) continue;
|
|
655
727
|
if(rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
656
728
|
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)};
|
|
@@ -661,6 +733,18 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
661
733
|
var asel=e.getAttribute('aria-selected'); if(asel!=null) base.selected=(asel==='true');
|
|
662
734
|
if(e.required || e.getAttribute('aria-required')==='true') base.required=true;
|
|
663
735
|
if(e.getAttribute('draggable')==='true') base.draggable=true;
|
|
736
|
+
// Site chrome (global header/nav — "Home", language picker, sign-in...) is real and stays
|
|
737
|
+
// clickable, just demoted to the end of the in-view rows so task content comes first.
|
|
738
|
+
try{ if(e.closest('nav,[role="navigation"]')) base.chrome=true; }catch(_){}
|
|
739
|
+
// Repeated links to the exact same destination (a card's photo, title, and "Opens X info"
|
|
740
|
+
// overlay often all point at the same URL): dedup key, hash dropped, bare "#"/javascript: skipped.
|
|
741
|
+
if(e.tagName==='A'){
|
|
742
|
+
// Same-PAGE hash-only hrefs ("#", "#1", "#panel-a") are cheap and collide across totally
|
|
743
|
+
// unrelated widgets (a sidebar's "#1" and a panel's "#1" resolve to the identical URL) — never
|
|
744
|
+
// dedup those. Only a link to a genuinely different page/resource is a safe, real duplicate.
|
|
745
|
+
var hattr=e.getAttribute('href')||'';
|
|
746
|
+
if(hattr && hattr.charAt(0)!=='#' && !/^javascript:/i.test(hattr)) base._dk=e.href;
|
|
747
|
+
}
|
|
664
748
|
// Only surface validation errors on fields the user (or agent) has put a value in, or that the
|
|
665
749
|
// page itself flags, so an untouched required form isn't a wall of warnings.
|
|
666
750
|
if(e.getAttribute('aria-invalid')==='true') base.invalid='invalid';
|
|
@@ -675,6 +759,8 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
675
759
|
base.off=y<0?'up':'down'; base.dist=y<0?-y:y-VH;
|
|
676
760
|
} else if((function(){ var fv=surf.ownerDocument.defaultView; return fv!==window && (lx<0||ly<0||lx>=fv.innerWidth||ly>=fv.innerHeight); })()){
|
|
677
761
|
base.off='scroll'; // scrolled out of its (same-origin) iframe's viewport
|
|
762
|
+
} else if(!hits(surf, lx, ly) && reachable(surf, lx, ly)){
|
|
763
|
+
base.off='scroll'; base.dist=1; // hidden under a sticky bar / a panel header: right here, scrolling brings it out
|
|
678
764
|
} else if(!hits(surf, lx, ly)){
|
|
679
765
|
// Not hittable: either scrolled out of an overflow container (reachable — acting scrolls it
|
|
680
766
|
// in) or genuinely covered by an overlay/modal (needs dismissing first).
|
|
@@ -709,11 +795,77 @@ const SNAPSHOT = `(function(seed, opts){
|
|
|
709
795
|
var omitted=Math.max(0, inView.length-250); inView.splice(250);
|
|
710
796
|
// Keep the 25 off-screen controls NEAREST the visible area (not the first 25 in DOM order, which
|
|
711
797
|
// for a scrolled list are the rows furthest behind), then restore page order.
|
|
712
|
-
var OFFCAP=opts.all?400:
|
|
798
|
+
var OFFCAP=opts.all?400:12; // default trimmed further: the nearest few off-screen rows are rarely the next target
|
|
713
799
|
var offMore=Math.max(0, offView.length-OFFCAP)+farOff;
|
|
714
800
|
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; }); }
|
|
801
|
+
inView.sort(function(a,b){ return (a.chrome?1:0)-(b.chrome?1:0); }); // stable: keeps relative order within each group
|
|
715
802
|
var actions=inView.concat(offView);
|
|
803
|
+
// Two rows with the IDENTICAL displayed label where one element contains the other, sitting at
|
|
804
|
+
// essentially the same spot, are the same control seen twice by our own heuristics — an outer
|
|
805
|
+
// clickable "cell" (aria-label="Tuesday, October 20, 2026") wrapping the real checkbox/radio/link
|
|
806
|
+
// with the same name inside it (a calendar day, a selectable list row). A small pixel tolerance
|
|
807
|
+
// absorbs the cell's own border/padding around an absolutely-positioned inner control. Keep the
|
|
808
|
+
// one with richer state (checked/selected/expanded) when only one side has it, else the innermost.
|
|
809
|
+
try{
|
|
810
|
+
var byLbl={};
|
|
811
|
+
actions.forEach(function(a){ var k=a.kind+'|'+a.label; (byLbl[k]=byLbl[k]||[]).push(a); });
|
|
812
|
+
var dropR=new Set();
|
|
813
|
+
Object.keys(byLbl).forEach(function(key){
|
|
814
|
+
var grp=byLbl[key]; if(grp.length<2) return;
|
|
815
|
+
for(var gi=0; gi<grp.length; gi++){
|
|
816
|
+
for(var gj=0; gj<grp.length; gj++){
|
|
817
|
+
if(gi===gj) continue;
|
|
818
|
+
var A=grp[gi], B=grp[gj]; if(dropR.has(A)||dropR.has(B)) continue;
|
|
819
|
+
var ea=cache.nodes.get(A.node), eb=cache.nodes.get(B.node);
|
|
820
|
+
if(!ea||!eb||ea===eb) continue;
|
|
821
|
+
var contains; try{ contains=eb.contains(ea); }catch(_){ contains=false; }
|
|
822
|
+
if(!contains) continue; // B is an ancestor of A, same label
|
|
823
|
+
if(Math.abs(A.x-B.x)>8 || Math.abs(A.y-B.y)>8) continue; // not the same visual spot: leave both
|
|
824
|
+
var richA=A.checked!=null||A.selected!=null||A.expanded!=null;
|
|
825
|
+
var richB=B.checked!=null||B.selected!=null||B.expanded!=null;
|
|
826
|
+
if(richB && !richA) dropR.add(A); else dropR.add(B); // prefer the innermost, unless only the ancestor carries state
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
if(dropR.size) actions=actions.filter(function(a){ return !dropR.has(a); });
|
|
831
|
+
}catch(_){}
|
|
832
|
+
// Repeated links to the same destination (see _dk above): keep the clearest copy (in view, not
|
|
833
|
+
// covered, shortest label), drop the rest.
|
|
834
|
+
try{
|
|
835
|
+
var byHref={};
|
|
836
|
+
actions.forEach(function(a){ if(a._dk) (byHref[a._dk]=byHref[a._dk]||[]).push(a); });
|
|
837
|
+
var dropH=new Set();
|
|
838
|
+
Object.keys(byHref).forEach(function(href){
|
|
839
|
+
var grp=byHref[href]; if(grp.length<2) return;
|
|
840
|
+
// Labels get combined below, so there's no information reason to prefer the shorter one —
|
|
841
|
+
// prefer the LARGER, more prominent element as the surviving click target's geometry instead
|
|
842
|
+
// (a tiny trailing ")" fragment next to a wide title link would otherwise become the anchor).
|
|
843
|
+
var score=function(a){ return (a.off?2:0)+(a.covered?2:0)-Math.min((a.w||0)*(a.h||0),40000)/40000; };
|
|
844
|
+
// Cluster by PROXIMITY first (a navbar shortcut and a hero CTA can legitimately share a
|
|
845
|
+
// destination while being two different, intentionally separate controls); only within the
|
|
846
|
+
// same small visual area is one of them a genuine repeat of the other.
|
|
847
|
+
var used=new Array(grp.length).fill(false);
|
|
848
|
+
for(var gi=0; gi<grp.length; gi++){
|
|
849
|
+
if(used[gi]) continue;
|
|
850
|
+
var cluster=[grp[gi]]; used[gi]=true;
|
|
851
|
+
for(var gj=gi+1; gj<grp.length; gj++){
|
|
852
|
+
if(used[gj]) continue;
|
|
853
|
+
if(Math.abs(grp[gi].x-grp[gj].x)<=200 && Math.abs(grp[gi].y-grp[gj].y)<=200){ cluster.push(grp[gj]); used[gj]=true; }
|
|
854
|
+
}
|
|
855
|
+
if(cluster.length<2) continue;
|
|
856
|
+
var keep=cluster.reduce(function(b,a){ return score(a)<score(b) ? a : b; });
|
|
857
|
+
// Different labels on the same destination ("7 hours ago" and "197 comments" both open the
|
|
858
|
+
// same story) each carry real information — combine them into the surviving row instead of
|
|
859
|
+
// silently discarding one, so the agent never loses "197 comments" for "7 hours ago".
|
|
860
|
+
var uniq=[]; cluster.forEach(function(a){ if(uniq.indexOf(a.label)<0) uniq.push(a.label); });
|
|
861
|
+
if(uniq.length>1) keep.label=clean(uniq.join(' · '),140);
|
|
862
|
+
cluster.forEach(function(a){ if(a!==keep) dropH.add(a); });
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
if(dropH.size) actions=actions.filter(function(a){ return !dropH.has(a); });
|
|
866
|
+
}catch(_){}
|
|
716
867
|
// The nearest ancestor text that isn't just the label itself: which row/item a control is in.
|
|
868
|
+
cache.ctxOf=function(el, lab){ return ctxOf(el, lab); };
|
|
717
869
|
function ctxOf(el, lab){
|
|
718
870
|
for(var p=el&&el.parentElement, g=0; p && g<6 && p.tagName!=='BODY'; p=p.parentElement, g++){
|
|
719
871
|
var tx=clean(p.innerText,400); if(!tx || tx===lab) continue;
|
|
@@ -1125,49 +1277,81 @@ async function observe(tabId, opts) {
|
|
|
1125
1277
|
|
|
1126
1278
|
// Re-resolve a ref to its live element, re-check it, and hit-test the center
|
|
1127
1279
|
// (elementFromPoint containment) so we never click a stale/covered/wrong target.
|
|
1128
|
-
async function resolveHit(tabId, ref, opts) {
|
|
1280
|
+
async function resolveHit(tabId, ref, opts, _retries) {
|
|
1281
|
+
_retries = _retries || 0;
|
|
1129
1282
|
const forFill = opts && opts.fill ? 'true' : 'false';
|
|
1130
1283
|
const noScroll = opts && opts.noScroll ? 'true' : 'false', noHit = opts && opts.noHit ? 'true' : 'false';
|
|
1284
|
+
const TXT = opts && opts.text != null ? JSON.stringify(String(opts.text)) : 'null';
|
|
1131
1285
|
const R = JSON.stringify(String(ref));
|
|
1132
|
-
|
|
1286
|
+
const result = await evaluate(tabId, `(function(){
|
|
1133
1287
|
var c=window.__pawbrowse; if(!c||!c.byId) return {error:'no snapshot yet; observe first'};
|
|
1134
1288
|
if(c.byId[${R}]==null) return {error:'unknown ref (observe again)'};
|
|
1135
1289
|
var e=c.get?c.get(${R}):null;
|
|
1136
1290
|
if(!e||!e.isConnected) return {error:'element no longer on page (observe again)'};
|
|
1137
1291
|
if(c.guard && c.guards && c.guards[${R}]!=null && c.guard(e)!==c.guards[${R}]) return {error:'element changed since observe (observe again)'};
|
|
1292
|
+
// Same node, same label — but a different ROW? Virtualized lists recycle row elements for other
|
|
1293
|
+
// items: "Delete" may now belong to someone else. The row context it was listed with must hold.
|
|
1294
|
+
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
1295
|
if(e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) return {error:'element is disabled'};
|
|
1139
1296
|
if(${forFill} && (e.readOnly||e.getAttribute('aria-readonly')==='true')) return {error:'field is read-only'};
|
|
1140
1297
|
if(${forFill} && !('value' in e) && !e.isContentEditable) return {error:'not an editable field (observe again)'};
|
|
1298
|
+
// Typed text that the field would reject (email/number/url/pattern/maxlength) is refused up front:
|
|
1299
|
+
// nothing is typed, instead of a value silently dropped or half-entered.
|
|
1300
|
+
if(${forFill} && ${TXT}!==null && ${TXT}!=='' && e.tagName==='INPUT'){
|
|
1301
|
+
var probe=e.cloneNode(); probe.value=${TXT};
|
|
1302
|
+
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)'};
|
|
1303
|
+
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)'};
|
|
1304
|
+
}
|
|
1141
1305
|
// Value-set inputs (date/time/range/color...) take the setter path, not click+type.
|
|
1142
1306
|
if(${forFill} && e.tagName==='INPUT' && ['date','time','datetime-local','month','week','color','range'].indexOf(e.type)>=0) return {set:true};
|
|
1143
1307
|
// Click the control's visible SURFACE: a styled checkbox's <label>, or the element itself.
|
|
1144
1308
|
var s=c.surface?c.surface(e):e;
|
|
1145
1309
|
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
1310
|
// 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;
|
|
1311
|
+
// coords, descending through nested open/closed shadow roots.
|
|
1166
1312
|
var sroot=function(n){ return n.shadowRoot || (c.closed && c.closed.get(n)) || null; };
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1313
|
+
var at=function(){
|
|
1314
|
+
var r=s.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
|
|
1315
|
+
var fw=s.ownerDocument.defaultView;
|
|
1316
|
+
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;
|
|
1317
|
+
// ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
|
|
1318
|
+
var dx=0, dy=0, w=fw, g=0;
|
|
1319
|
+
while(w && w.frameElement && g++<12){
|
|
1320
|
+
var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
|
|
1321
|
+
dx+=fr.left+fe.clientLeft+(parseFloat(fcs.paddingLeft)||0);
|
|
1322
|
+
dy+=fr.top+fe.clientTop+(parseFloat(fcs.paddingTop)||0);
|
|
1323
|
+
w=fe.ownerDocument.defaultView;
|
|
1324
|
+
}
|
|
1325
|
+
var inView=ly>=0 && lx>=0 && ly<fw.innerHeight && lx<fw.innerWidth && (r.height>fw.innerHeight || (r.top>=0 && r.bottom<=fw.innerHeight));
|
|
1326
|
+
var x=Math.round(lx+dx), y=Math.round(ly+dy);
|
|
1327
|
+
var root=s.getRootNode(); if(!root||!root.elementFromPoint) root=s.ownerDocument;
|
|
1328
|
+
var f=root.elementFromPoint(lx,ly), k=0;
|
|
1329
|
+
while(f && sroot(f) && k++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
|
|
1330
|
+
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)));
|
|
1331
|
+
return {x:x, y:y, inView:inView && x>=0 && y>=0 && x<innerWidth && y<innerHeight, hit:hit};
|
|
1332
|
+
};
|
|
1333
|
+
// Don't move the page when the target is already on screen and hittable: needless scrolling
|
|
1334
|
+
// closes popups/date pickers and jolts the page. Otherwise bring it to the centre and re-check.
|
|
1335
|
+
var p=at(); if(p.error) return p;
|
|
1336
|
+
if(!(p.inView && p.hit) && !${noScroll}){ s.scrollIntoView({block:'center',inline:'center',behavior:'instant'}); p=at(); if(p.error) return p; }
|
|
1337
|
+
if(!p.inView && !${noHit}) return {error:'element off-screen after scroll'};
|
|
1338
|
+
if(${noHit}) return {x:p.x, y:p.y};
|
|
1339
|
+
if(!p.hit) return {error:'element is covered by another element (dismiss the overlay/dialog first)'};
|
|
1340
|
+
return {x:p.x, y:p.y};
|
|
1170
1341
|
})()`);
|
|
1342
|
+
// A ref the page-side cache no longer recognises (its map was rebuilt, this tab's world was
|
|
1343
|
+
// recreated, or the element is mid-re-render from a JUST-PRIOR action — e.g. switching a
|
|
1344
|
+
// code-language combobox remounts the code block, including its own "Expand code" button, a beat
|
|
1345
|
+
// after the click that triggered it) is not necessarily gone: a fresh scan reruns the SAME
|
|
1346
|
+
// "gone element -> matching new element" rebind snapshot() already does for re-renders. Up to two
|
|
1347
|
+
// free retries with a short growing delay, transparent to the caller — costs nothing on success,
|
|
1348
|
+
// and never hides a real "no longer on page" failure.
|
|
1349
|
+
if(result && result.error === 'unknown ref (observe again)' && _retries < 2){
|
|
1350
|
+
await sleep(_retries === 0 ? 120 : 300);
|
|
1351
|
+
try { await snapshot(tabId, 1); } catch {}
|
|
1352
|
+
return resolveHit(tabId, ref, opts, _retries + 1);
|
|
1353
|
+
}
|
|
1354
|
+
return result;
|
|
1171
1355
|
}
|
|
1172
1356
|
|
|
1173
1357
|
// Set the value of a date/time/month/week/color/range input the way a user's picker would: via the
|
|
@@ -1236,7 +1420,7 @@ async function centerOfText(tabId, text) {
|
|
|
1236
1420
|
if(!pool.length) return null;
|
|
1237
1421
|
pool.sort(function(a,b){return a.area-b.area;});
|
|
1238
1422
|
var chosen=pool[0].el;
|
|
1239
|
-
chosen.scrollIntoView({block:'center',inline:'center'});
|
|
1423
|
+
chosen.scrollIntoView({block:'center',inline:'center',behavior:'instant'});
|
|
1240
1424
|
var rr=chosen.getBoundingClientRect();
|
|
1241
1425
|
var cx=Math.round(rr.left+rr.width/2), cy=Math.round(rr.top+rr.height/2);
|
|
1242
1426
|
if(cx<0||cy<0||cx>=innerWidth||cy>=innerHeight) return null;
|
|
@@ -1247,12 +1431,18 @@ async function centerOfText(tabId, text) {
|
|
|
1247
1431
|
|
|
1248
1432
|
async function clickAt(tabId, x, y, opts) {
|
|
1249
1433
|
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
|
|
1434
|
+
// `buttons` must say which button is held (CDP defaults it to 0; pointer-event widgets ignore a
|
|
1435
|
+
// pointerdown with no button). A double/triple click is press/release pairs with rising clickCount.
|
|
1436
|
+
// Pipelined, like Playwright: move, press and release reach the renderer together. Awaiting each
|
|
1437
|
+
// one leaves a gap in which a mousedown-triggered re-render (a blur committing a date, a ripple)
|
|
1438
|
+
// swaps the element, and Chromium then sends the click to a common ancestor — or nowhere.
|
|
1439
|
+
const held = { left: 1, right: 2, middle: 4 }[button];
|
|
1440
|
+
const sends = [sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y })];
|
|
1252
1441
|
for (let n = 1; n <= count; n++) {
|
|
1253
|
-
|
|
1254
|
-
|
|
1442
|
+
sends.push(sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, buttons: held, clickCount: n }));
|
|
1443
|
+
sends.push(sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, buttons: 0, clickCount: n }));
|
|
1255
1444
|
}
|
|
1445
|
+
await Promise.all(sends);
|
|
1256
1446
|
}
|
|
1257
1447
|
|
|
1258
1448
|
/* ------------------------------ Waiting (settle) ---------------------------- *
|
|
@@ -1263,10 +1453,11 @@ async function clickAt(tabId, x, y, opts) {
|
|
|
1263
1453
|
const watches = new Map(); // tabId -> { mainFrame, inflight: Map(reqId -> startedAt), navStart, navDone }
|
|
1264
1454
|
function tabWatch(tabId) {
|
|
1265
1455
|
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); }
|
|
1456
|
+
if (!w) { w = { mainFrame: null, inflight: new Map(), frameLoads: new Map(), navStart: 0, navDone: 0, committed: true, navReq: null }; watches.set(tabId, w); }
|
|
1267
1457
|
return w;
|
|
1268
1458
|
}
|
|
1269
|
-
|
|
1459
|
+
// Script too: SPA route changes lazy-load code chunks and render only after they run.
|
|
1460
|
+
const TRACKED = new Set(['Fetch', 'XHR', 'Document', 'Script']);
|
|
1270
1461
|
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1271
1462
|
if (source.tabId == null) return;
|
|
1272
1463
|
const w = watches.get(source.tabId);
|
|
@@ -1278,13 +1469,18 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
1278
1469
|
if (method === 'Network.loadingFailed' && params.requestId === w.navReq && !w.committed) w.navDone = Date.now(); // blocked/aborted navigation
|
|
1279
1470
|
return;
|
|
1280
1471
|
}
|
|
1472
|
+
// Same for frames: an out-of-process iframe starts loading in the parent, stops in its own session.
|
|
1473
|
+
if (method === 'Page.frameStoppedLoading' || method === 'Page.frameDetached') { w.frameLoads.delete(params.frameId); if (source.sessionId) return; }
|
|
1281
1474
|
if (source.sessionId) return; // everything else: the top-level target only
|
|
1282
1475
|
const now = Date.now();
|
|
1283
1476
|
const begin = (loaderId) => { w.navStart = now; w.navDone = 0; w.committed = false; w.navReq = loaderId || null; w.netIdle = 0; };
|
|
1284
1477
|
switch (method) {
|
|
1285
1478
|
case 'Network.requestWillBeSent':
|
|
1286
1479
|
// 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))
|
|
1480
|
+
if (TRACKED.has(params.type) && (params.type !== 'Document' || params.frameId === w.mainFrame)) {
|
|
1481
|
+
w.inflight.set(params.requestId, now);
|
|
1482
|
+
if (params.type === 'Script') w.lastScript = now; // a code chunk: the page is mid-transition
|
|
1483
|
+
}
|
|
1288
1484
|
if (params.type === 'Document' && params.frameId === w.mainFrame && params.requestId === params.loaderId && w.navReq !== params.requestId) {
|
|
1289
1485
|
if (w.navDone >= w.navStart) begin(params.requestId); else w.navReq = params.requestId;
|
|
1290
1486
|
}
|
|
@@ -1306,6 +1502,12 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
1306
1502
|
break;
|
|
1307
1503
|
case 'Page.frameStoppedLoading':
|
|
1308
1504
|
if (params.frameId === w.mainFrame && w.committed) w.navDone = now;
|
|
1505
|
+
else w.frameLoads.delete(params.frameId);
|
|
1506
|
+
break;
|
|
1507
|
+
case 'Page.frameStartedLoading':
|
|
1508
|
+
// An iframe (menu, widget, embed) that starts loading because of an action: its content is
|
|
1509
|
+
// part of the result, so waits follow it (capped like requests).
|
|
1510
|
+
if (params.frameId !== w.mainFrame) w.frameLoads.set(params.frameId, now);
|
|
1309
1511
|
break;
|
|
1310
1512
|
case 'Page.lifecycleEvent':
|
|
1311
1513
|
// Tied to the NEW document's loader, so the old page's late events can't end a wait.
|
|
@@ -1314,7 +1516,11 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
1314
1516
|
if (params.name === 'networkAlmostIdle' || params.name === 'networkIdle') w.netIdle = now;
|
|
1315
1517
|
}
|
|
1316
1518
|
break;
|
|
1317
|
-
case 'Page.
|
|
1519
|
+
case 'Page.navigatedWithinDocument':
|
|
1520
|
+
if (params.frameId === w.mainFrame) w.routeAt = now; // SPA route change (pushState)
|
|
1521
|
+
w.navDone = now;
|
|
1522
|
+
break;
|
|
1523
|
+
case 'Page.downloadWillBegin':
|
|
1318
1524
|
w.navDone = now;
|
|
1319
1525
|
break;
|
|
1320
1526
|
}
|
|
@@ -1324,7 +1530,13 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
1324
1530
|
const quietExpr = (since) => `(function(){ ${MO_INSTALL}
|
|
1325
1531
|
var now=performance.now(), s=${Number(since) || 0}-Date.now()+now, b={};
|
|
1326
1532
|
M.times.forEach(function(t){ if(t<s && t>=s-600) b[Math.floor((s-t)/100)]=1; });
|
|
1327
|
-
|
|
1533
|
+
// Finite CSS transitions/animations still running (a menu fading in, a panel sliding open):
|
|
1534
|
+
// until they finish, their contents may still be invisible. Infinite ones (spinners) don't count.
|
|
1535
|
+
// Those the ACTION started (startTime after it) are counted separately: they matter even on a page
|
|
1536
|
+
// that animates constantly in the background (a panel's staggered entrance on a carousel page).
|
|
1537
|
+
var anim=0, animNew=0;
|
|
1538
|
+
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(_){}
|
|
1539
|
+
return [anim ? 0 : now-M.last, Object.keys(b).length>=4, animNew];
|
|
1328
1540
|
})()`;
|
|
1329
1541
|
|
|
1330
1542
|
// Network tracking only while an action is being watched (see attach()).
|
|
@@ -1392,14 +1604,27 @@ async function settle(tabId, capMs, since, opts) {
|
|
|
1392
1604
|
if (!w.inflight.has(id)) { rel.delete(id); windowEnd = Math.max(windowEnd, now + 150); continue; } // finished: allow a follow-up
|
|
1393
1605
|
if (now - w.inflight.get(id) < 8000) busy = true;
|
|
1394
1606
|
}
|
|
1607
|
+
for (const [fid, t] of w.frameLoads) {
|
|
1608
|
+
if (now - t > 8000) { w.frameLoads.delete(fid); continue; }
|
|
1609
|
+
if (t >= start - 50 && now - t < 2500) busy = true; // an embed/menu frame; ads shouldn't hold us long
|
|
1610
|
+
}
|
|
1395
1611
|
if (busy) { idleSince = 0; await sleep(30); continue; }
|
|
1396
1612
|
if (now < grace) { await sleep(30); continue; } // debounce window: a request may still be coming
|
|
1397
1613
|
if (!idleSince) idleSince = now;
|
|
1398
|
-
|
|
1399
|
-
|
|
1614
|
+
// A route change or a freshly loaded code chunk means a new view is being built: it often goes
|
|
1615
|
+
// quiet for a beat (JS executing, timers) before rendering, so ask for a longer quiet period.
|
|
1616
|
+
const transition = (w.routeAt || 0) >= start - 50 || (w.lastScript || 0) >= start - 50;
|
|
1617
|
+
const needQuiet = transition ? 250 : 60, quietCap = transition ? 1500 : 600;
|
|
1618
|
+
let quiet = 1e9, ambient = false, animNew = 0;
|
|
1619
|
+
const tq = Date.now();
|
|
1620
|
+
try { [quiet, ambient, animNew] = await evaluate(tabId, quietExpr(start)); } catch { await sleep(30); continue; } // document swapping
|
|
1621
|
+
// If even this tiny probe waited >50ms to run, the page's main thread was busy (JS/render):
|
|
1622
|
+
// not quiet, whatever the observers have reported so far.
|
|
1623
|
+
if (Date.now() - tq > 50) quiet = 0;
|
|
1400
1624
|
// DOM still changing: wait for 60ms of quiet, capped at 600ms after the network went idle, or
|
|
1401
1625
|
// 120ms on a page that was already constantly mutating (clocks, tickers, carousels) before us.
|
|
1402
|
-
if (
|
|
1626
|
+
if (animNew && now - idleSince < 1500) { await sleep(40); continue; } // the action's own animations
|
|
1627
|
+
if (quiet < needQuiet && now - idleSince < (ambient ? 120 : quietCap)) { await sleep(Math.max(10, Math.min(60, needQuiet - quiet))); continue; }
|
|
1403
1628
|
break;
|
|
1404
1629
|
}
|
|
1405
1630
|
}
|
|
@@ -1476,6 +1701,27 @@ async function pressKey(tabId, combo) {
|
|
|
1476
1701
|
return null;
|
|
1477
1702
|
}
|
|
1478
1703
|
|
|
1704
|
+
/* ------------------------- Tabs opened by an action -------------------------- *
|
|
1705
|
+
* target=_blank links and window.open() open a NEW tab: the agent would otherwise keep looking at
|
|
1706
|
+
* the old one and see "page did NOT change". A tab opened by the tab we're acting on is adopted
|
|
1707
|
+
* into the session (and its group) and becomes the one we drive. */
|
|
1708
|
+
const openedBy = new Map(); // opener tabId -> newest tab it opened during an action
|
|
1709
|
+
chrome.tabs.onCreated.addListener((t) => { if (t.openerTabId != null && acting.has(t.openerTabId)) openedBy.set(t.openerTabId, t.id); });
|
|
1710
|
+
|
|
1711
|
+
async function followNewTab(session, tabId) {
|
|
1712
|
+
const nt = openedBy.get(tabId);
|
|
1713
|
+
openedBy.delete(tabId);
|
|
1714
|
+
if (nt == null) return null;
|
|
1715
|
+
const s = sessionState(session);
|
|
1716
|
+
s.activeTabId = nt; s.createdTabs.add(nt); tabOwner.set(nt, session); persistState();
|
|
1717
|
+
await ensureGroup(s, nt);
|
|
1718
|
+
// Let it get past about:blank and load, then read it like any navigation.
|
|
1719
|
+
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); }
|
|
1720
|
+
await attach(nt);
|
|
1721
|
+
await settle(nt, 3000);
|
|
1722
|
+
return nt;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1479
1725
|
/* ---------------------------------- WebMCP --------------------------------- *
|
|
1480
1726
|
* Pages that implement WebMCP (navigator.modelContext.registerTool / <form toolname>) describe
|
|
1481
1727
|
* their own actions with JSON schemas. Calling one is a single deterministic step instead of a
|
|
@@ -1536,6 +1782,58 @@ async function invokeTool(tabId, name, input) {
|
|
|
1536
1782
|
return `tool ${name}: ${res.status}${res.errorText ? ` (${res.errorText})` : ''}${out ? `\n output (untrusted page data): ${out.slice(0, 2000).replace(/\n/g, '\n ')}` : ''}`;
|
|
1537
1783
|
}
|
|
1538
1784
|
|
|
1785
|
+
// Wait until the target's box stops moving (an animation, a sliding panel) before clicking it —
|
|
1786
|
+
// Playwright's "stable" check. Capped: a forever-spinning element is clicked anyway.
|
|
1787
|
+
async function waitStable(target, ref) {
|
|
1788
|
+
const R = JSON.stringify(String(ref));
|
|
1789
|
+
try {
|
|
1790
|
+
await evaluate(target, `new Promise(function(res){
|
|
1791
|
+
var c=window.__pawbrowse, e=c&&c.get&&c.get(${R}), s=e&&(c.surface?c.surface(e):e); if(!s) return res(0);
|
|
1792
|
+
var key=function(){ var r=s.getBoundingClientRect(); return [r.x,r.y,r.width,r.height].map(Math.round).join(','); };
|
|
1793
|
+
// Moving = its box changed, or a finite animation is still running on it or an ancestor (an
|
|
1794
|
+
// orbit/slide can pause at its turning points, which looks "stable" for a frame or two).
|
|
1795
|
+
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; };
|
|
1796
|
+
// Fast path: nothing animating and the box unchanged over one frame -> go. Once it has been seen
|
|
1797
|
+
// moving, require two quiet frames in a row.
|
|
1798
|
+
// Nothing animating on it or its ancestors: click now (no frame wait). JS-driven motion is
|
|
1799
|
+
// still caught by the click-time hit check.
|
|
1800
|
+
if(!animating()) return res(1);
|
|
1801
|
+
var last=key(), same=0, moved=true, t0=performance.now();
|
|
1802
|
+
(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); })();
|
|
1803
|
+
})`);
|
|
1804
|
+
} catch {}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Click-time hit check (Playwright's hit-target interceptor): the FIRST trusted pointer/mouse event
|
|
1808
|
+
// of the click must land on the intended element (or what counts as it). If it would land on
|
|
1809
|
+
// something else — an overlay that appeared, a re-render — the whole gesture is stopped before the
|
|
1810
|
+
// wrong element sees it, and the op reports what intercepted it.
|
|
1811
|
+
async function armClick(target, ref) {
|
|
1812
|
+
const R = JSON.stringify(String(ref));
|
|
1813
|
+
return evaluate(target, `(function(){
|
|
1814
|
+
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;
|
|
1815
|
+
if(c.__arm) c.__arm.off();
|
|
1816
|
+
var st={ok:null, by:null}, types=['pointerdown','mousedown','pointerup','mouseup','click','auxclick','dblclick','contextmenu'];
|
|
1817
|
+
var hosted=function(f){ for(var r=s.getRootNode(); r && r.host; r=r.host.getRootNode()){ if(r.host===f) return true; } return false; };
|
|
1818
|
+
// The page REPLACED the target during the gesture (re-render on hover/mousedown): the new element
|
|
1819
|
+
// with the same identity (role + label) is the same control to a user.
|
|
1820
|
+
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; };
|
|
1821
|
+
var h=function(ev){
|
|
1822
|
+
if(!ev.isTrusted) return;
|
|
1823
|
+
if(st.ok===null){ var f=(ev.composedPath&&ev.composedPath()[0])||ev.target; if(f && f.nodeType!==1) f=f.parentElement;
|
|
1824
|
+
st.ok = c.accepts(s,f) || hosted(f) || (e!==s && c.accepts(e,f)) || replaced(f);
|
|
1825
|
+
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'; }
|
|
1826
|
+
if(st.ok===false){ ev.preventDefault(); ev.stopImmediatePropagation(); }
|
|
1827
|
+
};
|
|
1828
|
+
types.forEach(function(t){ window.addEventListener(t, h, true); });
|
|
1829
|
+
c.__arm={st:st, off:function(){ types.forEach(function(t){ window.removeEventListener(t, h, true); }); c.__arm=null; }};
|
|
1830
|
+
return true;
|
|
1831
|
+
})()`).catch(() => false);
|
|
1832
|
+
}
|
|
1833
|
+
async function disarmClick(target) {
|
|
1834
|
+
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);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1539
1837
|
const dragIntercepts = new Map(); // tabId -> drag data captured by Input.dragIntercepted
|
|
1540
1838
|
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1541
1839
|
if (method === 'Input.dragIntercepted' && source.tabId != null) dragIntercepts.set(source.tabId, params.data);
|
|
@@ -1600,12 +1898,16 @@ async function runOp(tabId, op) {
|
|
|
1600
1898
|
return `dialog: ${d.type} "${String(d.message || '').slice(0, 120)}" → ${op.accept ? 'accepted' : 'dismissed'}`;
|
|
1601
1899
|
}
|
|
1602
1900
|
case 'click': {
|
|
1901
|
+
await waitStable(T, REF);
|
|
1603
1902
|
const r = await resolveHit(T, REF);
|
|
1604
1903
|
if (r.error) return `${op.ref}: ${r.error}`;
|
|
1605
1904
|
const p = await top(r.x, r.y);
|
|
1606
1905
|
if (p.covered) return `${op.ref}: element is covered by another element of the page around its frame (dismiss the overlay first)`;
|
|
1607
1906
|
const button = ['right', 'middle'].includes(op.button) ? op.button : 'left';
|
|
1907
|
+
const armed = await armClick(T, REF);
|
|
1608
1908
|
await clickAt(tabId, p.x, p.y, { button, count: op.count });
|
|
1909
|
+
const verdict = armed ? await disarmClick(T) : null;
|
|
1910
|
+
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
1911
|
return `${op.count > 1 ? `${op.count}x ` : ''}${button !== 'left' ? `${button}-` : ''}click ${op.ref}`;
|
|
1610
1912
|
}
|
|
1611
1913
|
case 'hover': {
|
|
@@ -1643,7 +1945,7 @@ async function runOp(tabId, op) {
|
|
|
1643
1945
|
return `click_text "${op.text}"`;
|
|
1644
1946
|
}
|
|
1645
1947
|
case 'type': {
|
|
1646
|
-
const r = await resolveHit(T, REF, { fill: true });
|
|
1948
|
+
const r = await resolveHit(T, REF, { fill: true, text: op.text ?? '' });
|
|
1647
1949
|
if (r.error) return `${op.ref}: ${r.error}`;
|
|
1648
1950
|
if (r.set) {
|
|
1649
1951
|
const sv = await setValue(T, REF, op.text);
|
|
@@ -1697,6 +1999,19 @@ async function runOp(tabId, op) {
|
|
|
1697
1999
|
const u = await uploadFiles(T, REF, paths);
|
|
1698
2000
|
return u.error ? `${op.ref}: ${u.error}` : `upload ${op.ref} (${paths.length} file${paths.length > 1 ? 's' : ''})`;
|
|
1699
2001
|
}
|
|
2002
|
+
case 'back': case 'forward': {
|
|
2003
|
+
const { currentIndex, entries } = await sendCdp(tabId, 'Page.getNavigationHistory');
|
|
2004
|
+
const to = entries[currentIndex + (op.op === 'back' ? -1 : 1)];
|
|
2005
|
+
if (!to) return `${op.op}: no ${op.op === 'back' ? 'previous' : 'next'} page in this tab's history`;
|
|
2006
|
+
const w = tabWatch(tabId); w.navStart = Date.now(); w.navDone = 0; w.committed = false; w.navReq = null;
|
|
2007
|
+
await sendCdp(tabId, 'Page.navigateToHistoryEntry', { entryId: to.id });
|
|
2008
|
+
return `${op.op} → ${String(to.url).slice(0, 100)}`;
|
|
2009
|
+
}
|
|
2010
|
+
case 'reload': {
|
|
2011
|
+
const w = tabWatch(tabId); w.navStart = Date.now(); w.navDone = 0; w.committed = false; w.navReq = null;
|
|
2012
|
+
await sendCdp(tabId, 'Page.reload', {});
|
|
2013
|
+
return 'reload';
|
|
2014
|
+
}
|
|
1700
2015
|
case 'click_xy': {
|
|
1701
2016
|
// Coordinates from the last browser_screenshot image (converted to CSS px).
|
|
1702
2017
|
const k = shotScale.get(tabId) || 1;
|
|
@@ -1857,7 +2172,9 @@ async function handleCommand(cmd, args, token, session) {
|
|
|
1857
2172
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
1858
2173
|
await attach(tabId);
|
|
1859
2174
|
assertNoOpenDialog(tabId);
|
|
1860
|
-
|
|
2175
|
+
const t = await observe(tabId, args);
|
|
2176
|
+
const dl = takeDialogLog(tabId);
|
|
2177
|
+
return dl ? `${dl}\n${t}` : t;
|
|
1861
2178
|
}
|
|
1862
2179
|
case 'read': {
|
|
1863
2180
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
@@ -1907,6 +2224,11 @@ async function handleCommand(cmd, args, token, session) {
|
|
|
1907
2224
|
// the caller think they failed and retry them.
|
|
1908
2225
|
let table;
|
|
1909
2226
|
try { table = await observe(tabId); } catch { table = null; }
|
|
2227
|
+
const nt = await followNewTab(session, tabId).catch(() => null);
|
|
2228
|
+
if (nt != null) {
|
|
2229
|
+
const t2 = await observe(nt).catch(() => '(new tab not readable yet: observe next)');
|
|
2230
|
+
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}`;
|
|
2231
|
+
}
|
|
1910
2232
|
const changed = before == null || after == null || before !== after || table == null || seen == null || tableBody(table) !== seen || dialogLog.has(tabId);
|
|
1911
2233
|
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
2234
|
if (table == null) {
|
|
@@ -1915,6 +2237,25 @@ async function handleCommand(cmd, args, token, session) {
|
|
|
1915
2237
|
return `ran ${ops.length} op(s) [${note}]:\n${logLines.join('\n')}\n${takeDialogLog(tabId)}\n${(changed && deltaTable(seenFull, table)) || table}`;
|
|
1916
2238
|
});
|
|
1917
2239
|
}
|
|
2240
|
+
case 'peek': {
|
|
2241
|
+
// Dev-only frame capture for recordings: screenshot ANY tab by id without adopting it into a
|
|
2242
|
+
// session, grouping it, or enabling domains on it (so another tool driving that tab — e.g. for
|
|
2243
|
+
// a side-by-side benchmark — is not disturbed). Attaches the debugger only if needed.
|
|
2244
|
+
const tabId = Number(args.tabId);
|
|
2245
|
+
let t; try { t = await chrome.tabs.get(tabId); } catch { throw new Error(`tab ${args.tabId} not found`); }
|
|
2246
|
+
if (restrictedPage(t.url)) throw new Error('that tab is a browser page that cannot be captured');
|
|
2247
|
+
if (!attachedTabs.has(tabId)) {
|
|
2248
|
+
await new Promise((res, rej) => chrome.debugger.attach({ tabId }, '1.3', () => { const e = chrome.runtime.lastError; if (e && !/already attached/i.test(e.message)) rej(new Error(e.message)); else res(); }));
|
|
2249
|
+
peekOnly.add(tabId);
|
|
2250
|
+
}
|
|
2251
|
+
const q = Math.max(20, Math.min(90, Number(args.quality) || 60));
|
|
2252
|
+
const shot = await sendCdp(tabId, 'Page.captureScreenshot', { format: 'jpeg', quality: q });
|
|
2253
|
+
return { data: shot.data, t: Date.now(), url: t.url };
|
|
2254
|
+
}
|
|
2255
|
+
case 'peek_end': {
|
|
2256
|
+
for (const id of [...peekOnly]) { peekOnly.delete(id); if (!attachedTabs.has(id)) await new Promise((r) => chrome.debugger.detach({ tabId: id }, () => { void chrome.runtime.lastError; r(); })); }
|
|
2257
|
+
return { ok: true };
|
|
2258
|
+
}
|
|
1918
2259
|
case 'screenshot': {
|
|
1919
2260
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
1920
2261
|
await attach(tabId);
|
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.3",
|
|
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
|
},
|
|
@@ -190,6 +211,16 @@ const TOOLS = [
|
|
|
190
211
|
},
|
|
191
212
|
];
|
|
192
213
|
|
|
214
|
+
// Dev-only tools (recording benchmarks): hidden unless PAWBROWSE_DEV_TOOLS=1.
|
|
215
|
+
if (process.env.PAWBROWSE_DEV_TOOLS === '1') {
|
|
216
|
+
TOOLS.push({
|
|
217
|
+
name: 'browser_peek',
|
|
218
|
+
description: 'DEV: screenshot any tab by id (JPEG base64 + capture time) without adopting or changing it. For recording side-by-side benchmarks.',
|
|
219
|
+
inputSchema: { type: 'object', properties: { tabId: { type: 'number' }, quality: { type: 'number' }, end: { type: 'boolean' } } },
|
|
220
|
+
annotations: { title: 'Peek at a tab (dev)', readOnlyHint: true, openWorldHint: true },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
193
224
|
function textResult(obj) {
|
|
194
225
|
const text = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
|
|
195
226
|
return { content: [{ type: 'text', text }] };
|
|
@@ -208,8 +239,18 @@ async function callTool(name, args) {
|
|
|
208
239
|
case 'browser_navigate':return textResult(await callExtension('navigate', args));
|
|
209
240
|
case 'browser_observe': return textResult(await callExtension('observe', args));
|
|
210
241
|
case 'browser_read': return textResult(await callExtension('read', args));
|
|
211
|
-
case 'browser_act':
|
|
242
|
+
case 'browser_act': {
|
|
243
|
+
// Enforce the upload policy here: the extension can't see the filesystem, the server can.
|
|
244
|
+
for (const op of (args && Array.isArray(args.ops) ? args.ops : [])) {
|
|
245
|
+
if (op && op.op === 'upload') op.paths = [].concat(op.paths ?? op.path ?? []).map(checkUploadPath);
|
|
246
|
+
}
|
|
247
|
+
return textResult(await callExtension('act', args));
|
|
248
|
+
}
|
|
212
249
|
case 'browser_assert': return textResult(await callExtension('assert', args));
|
|
250
|
+
case 'browser_peek': {
|
|
251
|
+
if (process.env.PAWBROWSE_DEV_TOOLS !== '1') throw new Error('unknown tool: browser_peek');
|
|
252
|
+
return textResult(await callExtension(args.end ? 'peek_end' : 'peek', args));
|
|
253
|
+
}
|
|
213
254
|
case 'browser_screenshot': {
|
|
214
255
|
const r = await callExtension('screenshot', args);
|
|
215
256
|
return { content: [{ type: 'image', data: r.data, mimeType: r.mimeType }, { type: 'text', text: r.note }] };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pawbrowse",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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",
|