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 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,
@@ -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
- const where = source.sessionId ? { tabId, sessionId: source.sessionId } : tabId; // dialogs can come from frames
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 { if (acting.get(tabId) === mine) acting.delete(tabId); }
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
- var txt = (tag==='INPUT'||tag==='SELECT'||tag==='TEXTAREA'||e.isContentEditable) ? '' : [].map.call(e.childNodes,function(n){ return n.nodeType===3 ? n.textContent : (n.nodeType===1 && visible(n) ? name(n,seen) : ''); }).join(' ').trim();
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 hr=t.getBoundingClientRect(); lx=hr.x+hr.width/2; ly=hr.y+hr.height/2; }
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 r=surf.getBoundingClientRect(), lx=r.x+r.width/2, ly=r.y+r.height/2, x=lx+ox, y=ly+oy;
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, so shadow-DOM and iframe elements
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
- while(f && sroot(f) && k++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
1168
- if(!f || !(s===f || s.contains(f) || (s.control && s.control===f))) return {error:'element is covered by another element (dismiss the overlay/dialog first)'};
1169
- return {x:x, y:y};
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
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
1251
- // A double/triple click is successive press/release pairs with a rising clickCount.
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
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, clickCount: n });
1254
- await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, clickCount: n });
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
- const TRACKED = new Set(['Fetch', 'XHR', 'Document']);
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)) w.inflight.set(params.requestId, now);
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.downloadWillBegin': case 'Page.navigatedWithinDocument':
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
- return [now-M.last, Object.keys(b).length>=4];
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
- let quiet = 1e9, ambient = false;
1399
- try { [quiet, ambient] = await evaluate(tabId, quietExpr(start)); } catch { await sleep(30); continue; } // document swapping
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 (quiet < 60 && now - idleSince < (ambient ? 120 : 600)) { await sleep(Math.max(10, 60 - quiet)); continue; }
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
- return observe(tabId, args);
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) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "PawBrowse",
4
- "version": "0.6.1",
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': return textResult(await callExtension('act', args));
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.1",
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=60000 --test-concurrency=1 test/e2e/*.e2e.mjs"
12
+ "test:e2e": "node --test --test-timeout=240000 --test-concurrency=1 test/e2e/*.e2e.mjs"
13
13
  },
14
14
  "repository": {
15
15
  "type": "git",