@agent360/browser-mcp 1.25.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,12 +36,25 @@ function restoreSessions() {
36
36
  }
37
37
  if (validTabIds.size > 0) {
38
38
  const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
39
+ // Samme kollisionsfejl som i getSession, og derfor samme rettelse: `size + 1`
40
+ // genbruger et nummer der allerede er i brug. Her betyder det at to gendannede
41
+ // sessioner kan komme op med samme navn efter en genstart af service-workeren.
42
+ const brugte = new Set([...sessions.values()].map((x) => x.nummer).filter((n) => typeof n === 'number'));
43
+ let nummer = typeof data.nummer === 'number' ? data.nummer : 1;
44
+ while (brugte.has(nummer)) nummer++;
39
45
  sessions.set(Number(port), {
40
46
  tabIds: validTabIds,
41
47
  activeTabId,
42
48
  groupId: data.groupId || null,
43
- color: data.color || SESSION_COLORS[sessions.size % SESSION_COLORS.length],
44
- label: data.label || `Claude ${sessions.size + 1}`,
49
+ nummer,
50
+ // MAALT 22/8: her stod `data.color || …` og `data.label || …`. Bumpede
51
+ // kollisionsloekken nummeret, fulgte navn og farve IKKE med — de blev
52
+ // gendannet ordret fra lageret. To sessioner kunne saa have hvert sit
53
+ // nummer og stadig begge hedde "Claude 1" i samme farve. Og navnet er
54
+ // praecis dét brugeren ser paa fanegruppen.
55
+ color: SESSION_COLORS[(nummer - 1) % SESSION_COLORS.length],
56
+ label: `Claude ${nummer}`,
57
+ pid: typeof data.pid === 'number' ? data.pid : null,
45
58
  });
46
59
  }
47
60
  }
@@ -49,18 +62,114 @@ function restoreSessions() {
49
62
  return restorePromise;
50
63
  }
51
64
 
52
- function getSession(port) {
65
+ function getSession(port, pid) {
53
66
  if (!sessions.has(port)) {
54
- const idx = sessions.size % SESSION_COLORS.length;
67
+ // ── Hvorfor det laveste LEDIGE nummer, og ikke sessions.size + 1 (MAALT 22/8) ──
68
+ // Med `size + 1` genbruges et nummer der allerede er i brug, saa snart en chat
69
+ // lukker: tre chats hedder 1, 2, 3 · chat 1 lukker · size er nu 2 · naeste chat
70
+ // faar "Claude 3" — som chat 3 stadig hedder. To chats deler navn OG farve, og
71
+ // brugeren kan ikke se hvilken fanegruppe der hoerer til hvad.
72
+ //
73
+ // Det er en TREDJE mekanisme bag "alt hedder Claude 1", uafhaengig af de to andre
74
+ // (to udvidelser om samme socket, og adoption uden live-port-gate). Den her
75
+ // rammer ogsaa naar alt andet er rigtigt — man skal bare lukke en chat.
76
+ //
77
+ // Laveste ledige nummer genbruger frigivne pladser uden at kollidere, saa numrene
78
+ // bliver ved med at vaere smaa og laesbare. Farven foelger nummeret, saa to
79
+ // samtidige sessioner heller ikke kan faa samme farve.
80
+ const brugte = new Set([...sessions.values()].map((s) => s.nummer).filter((n) => typeof n === 'number'));
81
+ let nummer = 1;
82
+ while (brugte.has(nummer)) nummer++;
83
+
55
84
  sessions.set(port, {
56
85
  tabIds: new Set(),
57
86
  activeTabId: null,
58
87
  groupId: null,
59
- color: SESSION_COLORS[idx],
60
- label: `Claude ${sessions.size + 1}`,
88
+ nummer,
89
+ color: SESSION_COLORS[(nummer - 1) % SESSION_COLORS.length],
90
+ label: `Claude ${nummer}`,
91
+ pid: typeof pid === 'number' ? pid : null,
61
92
  });
62
93
  }
63
- return sessions.get(port);
94
+ const s = sessions.get(port);
95
+ // Foerste kald fra en genstartet server kan baere pid'en foer sessionen har den.
96
+ if (s.pid == null && typeof pid === 'number') s.pid = pid;
97
+ return s;
98
+ }
99
+
100
+ // Adopt tabs from a session whose MCP connection is gone (FIX-18: reconnect orphaning).
101
+ //
102
+ // Sessions are keyed on the MCP port. A clean shutdown sends session_disconnect and the
103
+ // tabs are closed. But an UNCLEAN drop (server restart, network blip, laptop sleep) leaves
104
+ // the session in storage while the client reconnects on a NEW port — so getSession() hands
105
+ // back an empty session. Observed damage: navigate() returns tab X while click() fails on
106
+ // tab Y, list_tabs() comes back empty, a fresh about:blank spawns per call, and the session
107
+ // label walks Claude 1 → 2 → 3 → 4. Every navigate→click pair breaks.
108
+ //
109
+ // Fix: before serving an unknown port, hand over the tabs of the largest session whose port
110
+ // is no longer in mcpPorts (the live-connection list the offscreen bridge keeps in storage).
111
+ // Keeps label/color so the user sees continuity instead of a renumbered session.
112
+ async function adoptOrphanedSession(port, pid) {
113
+ // Only ever fires for a session that OWNS NOTHING — a session with tabs is left alone.
114
+ if (sessions.has(port) && sessions.get(port).tabIds.size) return null;
115
+
116
+ // ── Hvorfor pid'en er afgoerende (regression fundet 16/8) ────────────────────
117
+ // Den oprindelige version adopterede den STOERSTE session uanset hvem den tilhoerte,
118
+ // med den begrundelse at Claude Code spreder kald over flere forbindelser. Men en
119
+ // helt NY chat ejer ogsaa ingenting — saa hver ny chat stjal den aktive chats faner
120
+ // OG dens identitet (linje: sessions.delete(best.port)). Donorens port forsvandt fra
121
+ // kortet, saa dens naeste kald adopterede tilbage. To chats byttede den samme ene
122
+ // session frem og tilbage: alt hed "Claude 1", og kun én ting kunne koere ad gangen.
123
+ //
124
+ // Det aegte behov er smallere: naar EN chats MCP-server genstarter, faar den en ny
125
+ // port og skal genfinde sine egne faner. Den situation kan skelnes praecist, fordi
126
+ // begge porte hoerer til den samme Claude Code-proces. Derfor: adoptér kun fra en
127
+ // session med samme pid. Mangler pid'en (gammel server mod ny udvidelse), adopteres
128
+ // slet ikke — hellere en frisk session end en stjaalet.
129
+ if (typeof pid !== 'number') return null;
130
+
131
+ // ── Anden halvdel af gaten (MAALT 21/8) ─────────────────────────────────────
132
+ // Pid'en alene raekker ikke. Foraelder-processen er IKKE en unik identitet: starter
133
+ // en klient flere MCP-servere fra den samme proces, har de alle samme pid — og saa
134
+ // adopterede de hinandens faner paa stribe. Maalt med fire samtidige sessioner:
135
+ // alle fik navnet "Claude 3", de tre aeldste mistede deres fane, og en session
136
+ // kunne skifte til en andens. Altsaa "alt hedder Claude 1", i sin rene form, med
137
+ // kun én udvidelse indlaest.
138
+ //
139
+ // Det manglende tjek staar allerede beskrevet oeverst i denne funktion: adoptér kun
140
+ // fra en session hvis port IKKE laengere er forbundet. Er donorens port stadig i
141
+ // live, er det en anden chat der arbejder lige nu — ikke en genstartet server.
142
+ // Listen vedligeholdes af broen (ws_status → mcpPorts) ved hver til- og frakobling.
143
+ const { mcpPorts = [] } = await chrome.storage.local.get({ mcpPorts: [] });
144
+ const levendePorte = new Set(mcpPorts.map(Number));
145
+
146
+ let best = null;
147
+ for (const [p, session] of sessions) {
148
+ if (p === port || !session.tabIds.size) continue;
149
+ if (session.pid !== pid) continue; // en anden chat — lad den vaere
150
+ if (levendePorte.has(Number(p))) continue; // donoren arbejder stadig — hænderne væk
151
+ if (!best || session.tabIds.size > best.session.tabIds.size) best = { port: p, session };
152
+ }
153
+ if (!best) return null;
154
+
155
+ // Verify at least one tab survives — an orphan whose tabs the user already closed is
156
+ // worthless, and adopting it would mask a genuinely fresh start.
157
+ const alive = new Set();
158
+ for (const tabId of best.session.tabIds) {
159
+ try { await chrome.tabs.get(tabId); alive.add(tabId); } catch {}
160
+ }
161
+ if (!alive.size) {
162
+ sessions.delete(best.port);
163
+ persistSessions();
164
+ return null;
165
+ }
166
+
167
+ best.session.tabIds = alive;
168
+ if (!alive.has(best.session.activeTabId)) best.session.activeTabId = null;
169
+ sessions.delete(best.port);
170
+ sessions.set(port, best.session);
171
+ persistSessions();
172
+ return best.session;
64
173
  }
65
174
 
66
175
  // LRU eviction cap: hver session må højst have N åbne tabs samtidigt.
@@ -69,7 +178,13 @@ function getSession(port) {
69
178
  // Begrundelse: Claude Code-flows kan åbne 20+ navigate(new_tab=true) per session
70
179
  // over en længere conversation. Uden eviction akkumulerer disse i Chrome som
71
180
  // orphan-tabs der spiser RAM + giver "extension localhost 19+" tab-noise.
72
- const MAX_TABS_PER_SESSION = 10;
181
+ //
182
+ // Hævet 10 → 20 (21/8). Ti var for lavt til reelle flows: en jagt der åbner en
183
+ // fane pr. udbyder ramte loftet midtvejs, og evictionen lukkede de faner arbejdet
184
+ // stadig byggede på — tavst, for eviction rapporterer ikke noget. Tyve matcher
185
+ // portspændet (9876-9895), så en session kan holde lige så mange faner som der
186
+ // kan køre samtidige sessioner.
187
+ const MAX_TABS_PER_SESSION = 20;
73
188
 
74
189
  async function evictOldestTabs(session, justAddedTabId) {
75
190
  // Drop dead tab-ids først (user manually closed dem)
@@ -96,6 +211,8 @@ async function evictOldestTabs(session, justAddedTabId) {
96
211
  async function addTabToSession(port, tabId) {
97
212
  const session = getSession(port);
98
213
  session.tabIds.add(tabId);
214
+ // Sessionen lever igen — aflys en eventuel port-frigivelse (se tabs.onRemoved).
215
+ chrome.alarms.clear(`frigiv-${port}`).catch(() => {});
99
216
 
100
217
  // LRU eviction: når sessionen overstiger cap, luk de ældste tabs.
101
218
  if (session.tabIds.size > MAX_TABS_PER_SESSION) {
@@ -132,6 +249,29 @@ async function releaseSession(port) {
132
249
  const session = sessions.get(port);
133
250
  if (!session) return;
134
251
 
252
+ // ── Bad vi selv om frigivelsen? (FUNDET AF REVIEW 7/9) ────────────────────
253
+ //
254
+ // Vejen fra "sessionen er tom" til denne funktion gaar over mindst tre hop:
255
+ // sendMessage -> offscreen sender terminate og lukker WS -> ws.onclose ->
256
+ // session_disconnect. Aabner agenten en fane i de ~50 ms undervejs, lukkede
257
+ // oprydningen herunder DEN fane — paa grundlag af en beslutning der blev truffet
258
+ // foer fanen fandtes. Det er praecis "agenten holder pause og genoptager"-
259
+ // scenariet frigivelsen er bygget til at understoette.
260
+ //
261
+ // En UVENTET afbrydelse (chatten er vaek) skal stadig lukke fanerne — derfor
262
+ // skelnes der, i stedet for bare at tjekke om sessionen er tom.
263
+ if (frivilligtFrigivet.delete(port)) {
264
+ if (session.tabIds.size) {
265
+ // Sessionen arbejder igen. Behold den, saa naeste binding kan adoptere den
266
+ // (adoptOrphanedSession finder donorer med samme pid OG faner).
267
+ persistSessions();
268
+ return;
269
+ }
270
+ sessions.delete(port);
271
+ persistSessions();
272
+ return;
273
+ }
274
+
135
275
  // Detach debugger + close all session tabs
136
276
  const tabIds = [...session.tabIds];
137
277
  for (const tabId of tabIds) {
@@ -154,6 +294,8 @@ function persistSessions() {
154
294
  groupId: session.groupId,
155
295
  color: session.color,
156
296
  label: session.label,
297
+ nummer: session.nummer ?? null, // uden denne mister en gendannet session sin plads og kan kollidere
298
+ pid: session.pid ?? null, // uden denne adopterer en genstartet service worker paa tvaers af chats igen
157
299
  };
158
300
  }
159
301
  chrome.storage.local.set({ sessions: data });
@@ -284,7 +426,13 @@ async function debuggerAttach(tabId) {
284
426
  lastMsg = 'attach resolved but Chrome shows tab not attached (ghost — page likely mid-reload)';
285
427
  try { await chrome.debugger.detach({ tabId }); } catch {}
286
428
  } catch (e) {
287
- if (e.message?.includes('Already attached')) {
429
+ // MAALT 31/8 af den nye udvidelses-test: her stod `includes('Already attached')`
430
+ // med stort A. Chromes faktiske besked er "Another debugger is already attached
431
+ // to the tab with id: N" — med lille. Tjekket ramte ALDRIG. Resultat: naar en
432
+ // anden debugger havde fanen (DevTools aabent, en anden udvidelse), blev det
433
+ // behandlet som en fejl, proevet tre gange, og kastet — i stedet for bare at
434
+ // bruge den session der allerede fandtes.
435
+ if (/already attached/i.test(e.message || '')) {
288
436
  // Chrome side has session — sync local cache
289
437
  debuggerAttached.add(tabId);
290
438
  return;
@@ -384,15 +532,74 @@ async function cdpSend(tabId, method, params = {}) {
384
532
  }
385
533
 
386
534
  // Clean up debugger + session refs when tabs close
535
+ // Faner agenten selv lukkede via close_tab. En tom session betyder kun "arbejdet er slut"
536
+ // hvis det var BRUGEREN der lukkede den sidste fane.
537
+ const agentLukkedeFaner = new Set();
538
+ // Porte hvor VI selv har bedt serveren slippe porten, fordi sessionen var tom.
539
+ // Skelnen betyder alt i releaseSession: en frivillig frigivelse maa aldrig lukke
540
+ // faner, mens en uventet afbrydelse (chatten er vaek) netop skal rydde op.
541
+ const frivilligtFrigivet = new Set();
542
+
543
+ // ── Armerede dialog-haandterere, pr. fane ──────────────────────────────────────
544
+ // MAALT 22/8 af flowtesten: handle_dialog var ubrugelig som den var skrevet. Den
545
+ // BLOKEREDE i op til 10 sekunder mens den ventede paa en dialog — men en alert(),
546
+ // confirm() eller prompt() dukker foerst op naar man klikker paa noget, og klikket
547
+ // kan ikke ske mens kaldet blokerer. Man kunne altsaa hverken arme den foerst eller
548
+ // kalde den bagefter: naar dialogen foerst staar der, er hele fanen laast, og
549
+ // klik-vaerktoejet naar ikke frem. Vaerktoejet kunne kun lykkes hvis en ANDEN aabnede
550
+ // dialogen paa praecis det rigtige tidspunkt.
551
+ //
552
+ // Nu armerer den og vender tilbage med det samme. Lytteren bliver siddende og tager
553
+ // den naeste dialog paa fanen. `wait: true` giver den gamle blokerende adfaerd for
554
+ // de tilfaelde hvor dialogen allerede er undervejs.
555
+ const armeredeDialoger = new Map();
556
+ // Loeftet for den senest armerede dialog pr. fane. Ligger UDEN for armeredeDialoger,
557
+ // fordi lytteren afvaebner i samme oejeblik dialogen aabner — og klikket skal kunne
558
+ // vente paa svaret BAGEFTER.
559
+ const dialogLoefter = new Map(); // tabId -> Promise // tabId → { listener, timer, action }
560
+
561
+ function afvaebnDialog(tabId, grund) {
562
+ const a = armeredeDialoger.get(tabId);
563
+ if (!a) return;
564
+ try { chrome.debugger.onEvent.removeListener(a.listener); } catch {}
565
+ clearTimeout(a.timer);
566
+ armeredeDialoger.delete(tabId);
567
+ // MAALT 22/8: her stoppede funktionen. Loeftet blev ALDRIG opfyldt, saa en kalder
568
+ // med `wait: true` haengte for evigt ad to helt almindelige veje — fanen blev
569
+ // lukket, eller et andet handle_dialog armerede paa samme fane. Ingen fejl, intet
570
+ // svar, bare stilhed. Nu faar kalderen altid et svar.
571
+ if (grund && a.opfyld) a.opfyld({ ok: false, error: grund });
572
+ }
573
+
387
574
  chrome.tabs.onRemoved.addListener((tabId) => {
575
+ afvaebnDialog(tabId, 'fanen blev lukket foer der kom en dialog');
576
+ const lukketAfAgenten = agentLukkedeFaner.delete(tabId);
388
577
  debuggerAttached.delete(tabId);
389
578
  for (const [port, session] of sessions) {
390
579
  if (!session.tabIds.has(tabId)) continue;
391
580
  session.tabIds.delete(tabId);
392
- if (session.tabIds.size === 0) {
581
+ if (session.tabIds.size === 0 && !lukketAfAgenten) {
393
582
  // Last tab closed — tell offscreen to terminate the MCP server.
394
583
  // Resulting WS-close triggers the existing session_disconnect → releaseSession path.
584
+ frivilligtFrigivet.add(port);
395
585
  chrome.runtime.sendMessage({ type: 'terminate_mcp_session', port }).catch(() => {});
586
+ } else if (session.tabIds.size === 0) {
587
+ // ── Agenten lukkede selv sin sidste fane (MAALT 7/9-2026) ────────────────
588
+ // Her stod der intet, og det var med vilje: en agent der lukker en fane midt i
589
+ // et forloeb skal ikke miste browseren. Men serverens EGEN instruks siger til
590
+ // hver eneste agent: "ALWAYS close tabs when done". Hver velopdragen chat endte
591
+ // altsaa med at holde sin port til 4-timers-tomgangen udloeb — den dokumenterede
592
+ // god-praksis slog oprydningen ihjel, og 20 porte kunne staa optaget af chats
593
+ // der for laengst var faerdige.
594
+ //
595
+ // Nu: fem minutters henstand. Kommer der en ny fane inden da, aflyses den
596
+ // (se getSessionTab). Sker der intet, bedes serveren slippe porten — den DOER
597
+ // ikke, saa chatten kan hente browseren tilbage naar som helst.
598
+ //
599
+ // chrome.alarms og ikke setTimeout: en MV3-service-worker suspenderes, og en
600
+ // timer ville forsvinde med den.
601
+ chrome.alarms.create(`frigiv-${port}`, { delayInMinutes: 5 });
602
+ persistSessions();
396
603
  } else {
397
604
  persistSessions();
398
605
  }
@@ -460,6 +667,51 @@ async function debuggerType(tabId, text) {
460
667
  }
461
668
  }
462
669
 
670
+ // ── Naar en dialog kan aabne midt i klikket (MAALT 24/8) ─────────────────────
671
+ //
672
+ // Et confirm() fryser rendereren, og Chrome svarer ALDRIG paa den CDP-kommando der
673
+ // udloeste det. Maalt: browser_click haengte i 25,9 sekunder og gav aldrig svar —
674
+ // mens dialogen faktisk BLEV besvaret af den armerede haandtering (siden rapporterede
675
+ // window.__svar === true). Klikket var altsaa leveret; kun svaret var vaek.
676
+ //
677
+ // Er der armeret en dialog paa fanen, venter vi derfor kun kort. Kommer der intet
678
+ // svar, ER klikket leveret — det er jo netop dét der aabnede dialogen.
679
+ async function dispatchTaalmodigt(tabId, params) {
680
+ if (!armeredeDialoger.has(tabId)) {
681
+ return cdpSend(tabId, 'Input.dispatchMouseEvent', params);
682
+ }
683
+ let faerdig = false;
684
+ const kald = cdpSend(tabId, 'Input.dispatchMouseEvent', params)
685
+ .then((r) => { faerdig = true; return r; })
686
+ .catch(() => { faerdig = true; });
687
+ await Promise.race([kald, new Promise((r) => setTimeout(r, 1200))]);
688
+ return faerdig ? kald : { __dialogBlokerede: true };
689
+ }
690
+
691
+ // MAALT 28/8: `dispatchTaalmodigt` ovenfor lukkede deadlocken paa museklikket, men
692
+ // settle-opslaget i step 3 er OGSAA et renderer-kald — og maalingen viste at det er
693
+ // PRAECIS der klikket haenger (HAENGER@step3-settle). Deadlocken var kun lukket paa
694
+ // hovedstien.
695
+ //
696
+ // Foerste forsoeg gjorde kuren betinget af at vi kunne SE en dialog (armeret, eller en
697
+ // dispatch der blokerede). Den betingelse holder ikke: dispatchen kan naa at blive
698
+ // kvitteret, og lytteren kan naa at afvaebne, FOER rendereren gaar i staa. Saa faldt vi
699
+ // tilbage i det ubeskyttede kald og hang alligevel — maalt.
700
+ //
701
+ // Derfor er fristen nu betingelsesloes. Opslaget er et par linjers synkron JS: svarer
702
+ // rendereren, er den tilbage paa millisekunder. Bruger den over tre sekunder, er den
703
+ // blokeret — ikke langsom. Saa svarer vi aerligt i stedet for at vente i 30.
704
+ // Et klik der aabnede en dialog ER landet, saa framework-fallbacken skal ikke fyre oveni.
705
+ async function evaluerTaalmodigt(tabId, params, ms = 3000) {
706
+ let faerdig = false;
707
+ const kald = cdpSend(tabId, 'Runtime.evaluate', params)
708
+ .then((r) => { faerdig = true; return r; })
709
+ .catch(() => { faerdig = true; });
710
+ await Promise.race([kald, new Promise((r) => setTimeout(r, ms))]);
711
+ if (faerdig) return kald;
712
+ return { result: { value: { landed: true, fallbackFired: false, rendererSvarede: false } } };
713
+ }
714
+
463
715
  async function debuggerClick(tabId, x, y) {
464
716
  await debuggerAttach(tabId);
465
717
  try {
@@ -497,7 +749,7 @@ async function debuggerClick(tabId, x, y) {
497
749
  })()`,
498
750
  });
499
751
  // 1. mouseMoved first (triggers hover state, required by some frameworks)
500
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
752
+ await dispatchTaalmodigt(tabId, {
501
753
  type: 'mouseMoved', x, y,
502
754
  });
503
755
  await new Promise(r => setTimeout(r, 30));
@@ -505,11 +757,11 @@ async function debuggerClick(tabId, x, y) {
505
757
  // 0 on release) plus a small press→release gap are REQUIRED for Chrome to
506
758
  // synthesize a *trusted* 'click' from the pair. Without them, web-components
507
759
  // that gate on the trusted click event (Google Ads, Material Web) never fire.
508
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
760
+ await dispatchTaalmodigt(tabId, {
509
761
  type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
510
762
  });
511
763
  await new Promise(r => setTimeout(r, 30));
512
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
764
+ await dispatchTaalmodigt(tabId, {
513
765
  type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
514
766
  });
515
767
  // 3. Framework fallback — only if the captured target is STILL connected (i.e.
@@ -517,14 +769,15 @@ async function debuggerClick(tabId, x, y) {
517
769
  // SPA re-renders (Google Ads) detach the element first. Fires a full pointer
518
770
  // + mouse sequence on the shadow-pierced target, then React/Angular handlers.
519
771
  await new Promise(r => setTimeout(r, 120));
520
- await cdpSend(tabId, 'Runtime.evaluate', {
772
+ const settle = await evaluerTaalmodigt(tabId, {
773
+ returnByValue: true,
521
774
  expression: `(() => {
522
775
  const el = window.__bmcpClickTarget;
523
776
  const landed = window.__bmcpClicked === true;
524
777
  try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
525
778
  try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
526
- if (landed) return; // FIX-13: trusted click already landed — do NOT double-fire
527
- if (!el || !el.isConnected) return; // already navigated/handled — don't double-fire
779
+ if (landed) return { landed: true, fallbackFired: false }; // FIX-13: trusted click already landed — do NOT double-fire
780
+ if (!el || !el.isConnected) return { landed: false, fallbackFired: false, detached: true }; // already navigated/handled — don't double-fire
528
781
  const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
529
782
  try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (e) {}
530
783
  el.dispatchEvent(new MouseEvent('mousedown', opts));
@@ -549,8 +802,24 @@ async function debuggerClick(tabId, x, y) {
549
802
  const matRipple = el.closest && el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
550
803
  if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
551
804
  }
805
+ return { landed: false, fallbackFired: true };
552
806
  })()`,
553
807
  });
808
+ const vaerdi = settle?.result?.value ?? null;
809
+
810
+ // MAALT 28/8: uden det her svarede klikket 5,5 sek FOER dialogen var besvaret, saa
811
+ // den NAESTE kommando ramte en stadig frossen side og ventede 15 sek forgaeves.
812
+ // Svarede rendereren ikke, ER der en dialog i vejen — saa vent til den er ude af
813
+ // verden, foer vi melder klikket faerdigt. Sidebonus: kalderen faar en side der
814
+ // rent faktisk er klar til naeste skridt.
815
+ if (vaerdi && vaerdi.rendererSvarede === false) {
816
+ const loefte = dialogLoefter.get(tabId);
817
+ if (loefte) {
818
+ await Promise.race([loefte, new Promise((r) => setTimeout(r, 8000))]);
819
+ dialogLoefter.delete(tabId);
820
+ }
821
+ }
822
+ return vaerdi;
554
823
  } finally {
555
824
  await debuggerDetach(tabId);
556
825
  }
@@ -829,6 +1098,13 @@ async function resolveElement(tabId, selectorStr) {
829
1098
  if (!el) return null;
830
1099
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
831
1100
  const r = el.getBoundingClientRect();
1101
+ // MAALT 21/8: et skjult element har rect 0x0 ved (0,0), saa midtpunktet blev (0,0)
1102
+ // og debuggerClick sendte et AEGTE museklik i sidens oeverste venstre hjoerne —
1103
+ // paa hvad der nu laa der (logo, menu, link) — og svarede ok:true. Det er ikke en
1104
+ // rapporteringsfejl men en handlingsfejl: vi klikker et andet sted end der blev bedt om.
1105
+ if (r.width <= 0 || r.height <= 0) {
1106
+ return { found: false, hidden: true, tag: el.tagName, rect: { w: r.width, h: r.height } };
1107
+ }
832
1108
  return { x: r.x + r.width / 2, y: r.y + r.height / 2, tag: el.tagName, found: true };
833
1109
  };
834
1110
 
@@ -850,6 +1126,9 @@ async function resolveElement(tabId, selectorStr) {
850
1126
  if (!el) return null;
851
1127
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
852
1128
  const r = el.getBoundingClientRect();
1129
+ if (r.width <= 0 || r.height <= 0) {
1130
+ return { found: false, hidden: true, tag: el.tagName, rect: { w: r.width, h: r.height } };
1131
+ }
853
1132
  return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, found: true };
854
1133
  })()
855
1134
  `);
@@ -866,6 +1145,9 @@ async function resolveElement(tabId, selectorStr) {
866
1145
  if (!el) return null;
867
1146
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
868
1147
  const r = el.getBoundingClientRect();
1148
+ if (r.width <= 0 || r.height <= 0) {
1149
+ return { found: false, hidden: true, tag: el.tagName, text: el.textContent?.trim().slice(0, 80), rect: { w: r.width, h: r.height } };
1150
+ }
869
1151
  return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, text: el.textContent?.trim().slice(0, 80), found: true };
870
1152
  })()
871
1153
  `);
@@ -874,15 +1156,121 @@ async function resolveElement(tabId, selectorStr) {
874
1156
 
875
1157
  // ── Offscreen Document Setup ───────────────────────────────────────────────
876
1158
 
1159
+ // MAALT 21/8: her stod kun `if (!await chrome.offscreen.hasDocument()) createDocument()`.
1160
+ // Den spurgte om dokumentet FANDTES — aldrig om det SVAREDE. Et dokument hvis script
1161
+ // aldrig blev indlaest (en CSP-afvisning raekker) taeller stadig som eksisterende, saa
1162
+ // hjerteslaget hvert minut gjorde ingenting, for evigt. Udvidelsen saa levende ud i
1163
+ // chrome://extensions, men havde ingen WebSocket og kunne ikke naas af noget — heller
1164
+ // ikke af reload_extension, som netop kraever den forbindelse den mangler. Eneste vej
1165
+ // ud var ↻ i haanden.
1166
+ //
1167
+ // Nu spoerges dokumentet om det er der. Svarer det ikke, erstattes det.
1168
+ async function offscreenSvarer() {
1169
+ try {
1170
+ const svar = await Promise.race([
1171
+ chrome.runtime.sendMessage({ type: 'bmcp_ping' }),
1172
+ new Promise((_, afvis) => setTimeout(() => afvis(new Error('intet svar')), 1500)),
1173
+ ]);
1174
+ if (svar?.ok !== true) return false;
1175
+ // "Svarer den?" er ikke nok — den skal ogsaa vaere den udgave vi koerer nu.
1176
+ // En bro fra en aeldre udgave svarer lige saa villigt, og saa blev den aldrig
1177
+ // udskiftet. Oplyser den ingen version, er den fra foer 1.27.1 og altsaa gammel.
1178
+ let vores = null;
1179
+ try { vores = chrome.runtime.getManifest().version; } catch {}
1180
+ if (!vores) return true; // kan vi ikke sammenligne, saa lad den vaere
1181
+ return svar.version === vores;
1182
+ } catch {
1183
+ return false; // ingen modtager, eller den svarede ikke i tide
1184
+ }
1185
+ }
1186
+
1187
+ // Genskabelsen er BEGRAENSET, og det er ikke pynt.
1188
+ //
1189
+ // "Svarer ikke" betyder ikke altid "doed". En AELDRE offscreen.js — fra foer
1190
+ // ping-lytteren fandtes — svarer heller ikke, og Chrome kan servere den fra cache
1191
+ // hen over en genindlaesning (maalt 21/8). Uden en graense ville hjerteslaget saa
1192
+ // lukke og genskabe en fuldt fungerende bro hvert minut, for evigt, og rive
1193
+ // WebSocket-forbindelsen ned hver gang. Kuren ville vaere vaerre end sygdommen.
1194
+ //
1195
+ // Derfor: hoejst tre forsoeg i traek. Er dokumentet aegte doedt, er ét nok. Er det
1196
+ // bare gammelt, koster det tre korte afbrydelser og saa faar det fred et stykke tid.
1197
+ //
1198
+ // MAALT 22/8 — og det var en fejl i denne blok: graensen var PERMANENT. Naaede
1199
+ // taelleren tre, blev der aldrig forsoegt igen, og taelleren nulstilles kun naar en
1200
+ // ping lykkes — hvilket en doed bro pr. definition aldrig goer. Resultatet var en
1201
+ // doed bro der laa doed for evigt, hvor symptomet for brugeren er "browser-mcp
1202
+ // virker ikke", og hvor den eneste udvej var at genindlaese udvidelsen i haanden.
1203
+ // Praecis den tilstand vaernet skulle forhindre.
1204
+ //
1205
+ // Rettelsen: graensen er nu tidsbestemt, ikke endelig. Efter tre forsoeg holder vi
1206
+ // pause — og naar pausen er ovre, proever vi igen. En gammel-men-fungerende bro
1207
+ // faar altsaa ro i pausen i stedet for at blive revet ned hvert minut, og en aegte
1208
+ // doed bro er hoejst én pause fra at blive erstattet. Begge hensyn er i behold.
1209
+ const MAX_OFFSCREEN_GENSKAB = 3;
1210
+ const OFFSCREEN_PAUSE_MS = 10 * 60 * 1000;
1211
+
877
1212
  async function ensureOffscreen() {
878
- const existing = await chrome.offscreen.hasDocument();
879
- if (!existing) {
880
- await chrome.offscreen.createDocument({
881
- url: 'offscreen.html',
882
- reasons: ['WORKERS'],
883
- justification: 'Maintain persistent WebSocket connection to local MCP server',
884
- });
1213
+ const findes = await chrome.offscreen.hasDocument();
1214
+
1215
+ if (findes) {
1216
+ if (await offscreenSvarer()) {
1217
+ await chrome.storage.local.set({ offscreenGenskabt: 0 }); // levende — nulstil
1218
+ return;
1219
+ }
1220
+ // Taelleren skal ligge i storage, ikke i en modul-variabel: service-workeren
1221
+ // genstartes hele tiden, og en variabel ville nulstilles ved hver genstart —
1222
+ // altsaa ingen graense i praksis.
1223
+ let { offscreenGenskabt = 0 } =
1224
+ await chrome.storage.local.get({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
1225
+ const { offscreenPauseTil = 0 } =
1226
+ await chrome.storage.local.get({ offscreenPauseTil: 0 });
1227
+
1228
+ const nu = Date.now();
1229
+ if (offscreenPauseTil > nu) return; // midt i pausen — lad broen vaere
1230
+
1231
+ if (offscreenPauseTil > 0) {
1232
+ // Pausen er udloebet. Taelleren SKAL nulstilles her, foer graensen tjekkes —
1233
+ // ellers rammer vi graensen igen med det samme, saetter endnu en pause, og
1234
+ // graensen er i praksis permanent alligevel, bare med et ekstra skridt.
1235
+ // (Maalt 22/8: det var praecis den fejl den foerste udgave af rettelsen havde.
1236
+ // Testen "naar pausen er ovre, proeves der igen" fangede den.)
1237
+ offscreenGenskabt = 0;
1238
+ await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
1239
+ }
1240
+
1241
+ if (offscreenGenskabt >= MAX_OFFSCREEN_GENSKAB) {
1242
+ // Pause i stedet for at give op. Taelleren nulstilles samtidig, saa naeste
1243
+ // runde faar sine egne tre forsoeg — ellers ville graensen vaere permanent
1244
+ // alligevel, bare med et ekstra skridt.
1245
+ console.warn(`[BG] offscreen-dokumentet svarer stadig ikke efter ${MAX_OFFSCREEN_GENSKAB} ` +
1246
+ `forsoeg — holder pause i ${OFFSCREEN_PAUSE_MS / 60000} min og proever saa igen. ` +
1247
+ 'Haster det: chrome://extensions → slaa udvidelsen fra og til.');
1248
+ await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: nu + OFFSCREEN_PAUSE_MS });
1249
+ return;
1250
+ }
1251
+ console.warn(`[BG] offscreen-dokumentet svarer ikke — erstatter det (forsoeg ${offscreenGenskabt + 1}/${MAX_OFFSCREEN_GENSKAB})`);
1252
+ await chrome.storage.local.set({ offscreenGenskabt: offscreenGenskabt + 1 });
1253
+ try { await chrome.offscreen.closeDocument(); } catch (e) {
1254
+ console.warn('[BG] kunne ikke lukke det doede dokument:', e?.message || e);
1255
+ return; // proev igen ved naeste hjerteslag
1256
+ }
885
1257
  }
1258
+
1259
+ // Versionen foelger med i URL'en. Et offscreen-dokument har IKKE
1260
+ // chrome.runtime.getManifest() — kaldet kaster "is not a function", saa dokumentet
1261
+ // kunne aldrig oplyse sin version, `offscreenSvarer()` sammenlignede null mod vores
1262
+ // og fik altid falsk, og broen blev revet ned tre gange hvert tiende minut for evigt.
1263
+ // (Symptomet var "Offscreen document closed before fully loading" — nedrivningen
1264
+ // ramte dokumentet mens det stadig startede op.)
1265
+ // URL'en er baaret af dokumentet selv: et dokument oprettet af en aeldre udgave
1266
+ // baerer den aeldre version, hvilket er praecis den skelnen tjekket skal bruge.
1267
+ let minVersion = '';
1268
+ try { minVersion = chrome.runtime.getManifest().version; } catch {}
1269
+ await chrome.offscreen.createDocument({
1270
+ url: 'offscreen.html' + (minVersion ? '?v=' + encodeURIComponent(minVersion) : ''),
1271
+ reasons: ['WORKERS'],
1272
+ justification: 'Maintain persistent WebSocket connection to local MCP server',
1273
+ });
886
1274
  }
887
1275
 
888
1276
  // ── Action Logging ─────────────────────────────────────────────────────────
@@ -913,12 +1301,19 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
913
1301
  if (msg.type === 'mcp_command') {
914
1302
  const port = msg.port;
915
1303
  logAction(port, msg.method, msg.params);
916
- // Restore sessions from storage (service worker may have restarted)
917
- restoreSessions().then(() => {
918
- dispatch(port, msg.method, msg.params)
919
- .then(result => sendResponse(result))
920
- .catch(err => sendResponse({ __error: err.message || String(err) }));
921
- }).catch(err => sendResponse({ __error: err.message || String(err) })); // else a storage-restore reject hangs the caller
1304
+ // Restore sessions from storage (service worker may have restarted), then take over any
1305
+ // session orphaned by an unclean disconnect so navigate→click keeps hitting the same tab.
1306
+ restoreSessions()
1307
+ .then(() => adoptOrphanedSession(port, msg.pid).catch(() => null)) // adoption is best-effort, never fatal
1308
+ .then(() => {
1309
+ // Stempl pid'en ÉT sted, foer dispatch. De fem nedstroems getSession(port)-kald
1310
+ // arver den derfra, saa ingen af dem behoever at kende til pid-begrebet.
1311
+ getSession(port, msg.pid);
1312
+ dispatch(port, msg.method, msg.params)
1313
+ .then(result => sendResponse(result))
1314
+ .catch(err => sendResponse({ __error: err.message || String(err) }));
1315
+ })
1316
+ .catch(err => sendResponse({ __error: err.message || String(err) })); // else a storage-restore reject hangs the caller
922
1317
  return true; // async response
923
1318
  }
924
1319
 
@@ -928,10 +1323,28 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
928
1323
  }
929
1324
 
930
1325
  if (msg.type === 'reconnect') {
931
- chrome.offscreen.hasDocument().then(exists => {
932
- if (exists) chrome.offscreen.closeDocument().then(() => ensureOffscreen());
933
- else ensureOffscreen();
934
- });
1326
+ // FEJL RETTET 19/8: der var ingen fangst her. Lykkedes closeDocument()
1327
+ // men fejlede ensureOffscreen() fx fordi dokumentet stadig var ved at
1328
+ // lukke — blev afvisningen slugt, og extensionen stod tilbage UDEN
1329
+ // offscreen-dokument. Ingen WebSocket, ingen genopretning, og kun en
1330
+ // manuel genindlaesning kunne redde den.
1331
+ (async () => {
1332
+ try {
1333
+ if (await chrome.offscreen.hasDocument()) {
1334
+ await chrome.offscreen.closeDocument();
1335
+ }
1336
+ } catch (e) {
1337
+ console.warn('[BG] kunne ikke lukke offscreen:', e?.message || e);
1338
+ }
1339
+ // Proev at genskabe. Fejler det, tager hjerteslags-alarmen den
1340
+ // inden for et minut — men kun fordi vi IKKE lader fejlen forsvinde.
1341
+ try {
1342
+ await ensureOffscreen();
1343
+ } catch (e) {
1344
+ console.error('[BG] kunne ikke genskabe offscreen:', e?.message || e);
1345
+ setTimeout(() => ensureOffscreen().catch(console.error), 2000);
1346
+ }
1347
+ })();
935
1348
  return;
936
1349
  }
937
1350
 
@@ -1422,26 +1835,73 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1422
1835
  ];
1423
1836
  // "Ambiguous" texts MAY revert partial form data ("Cancel" usually reverts state)
1424
1837
  // — only used when overlay has no editable form fields, or in aggressive scope
1838
+ // ── VETO-LISTE (MAALT 23/8 — det dyreste fund i hele auditten) ────────────
1839
+ //
1840
+ // Koert med den ORDRETTE kode mod knapper i en rigtig browser trykkede
1841
+ // dismiss_overlays paa "Close account", "Close and delete everything",
1842
+ // "Cancel subscription" og "Afvis betalingen permanent" — og paa enhver knap
1843
+ // med aria-label="Close account" eller "Luk kontoen". Det skete i DEFAULT-scope,
1844
+ // ikke kun aggressive.
1845
+ //
1846
+ // Aarsagen: "close" og "luk" er lovlige luk-ord, og matchningen havde ingen
1847
+ // ord-graense. "Close account" indeholder "close". Og instruktionerne beder
1848
+ // agenten kalde dismiss_overlays FOER hvert stoerre skridt, saa det ville ske
1849
+ // paa hver eneste side hvor saadan en knap findes.
1850
+ //
1851
+ // Et luk-ord er derfor ikke laengere nok: findes ET af disse ord i teksten eller
1852
+ // aria-labelen, klikkes der ALDRIG — uanset hvor godt resten matcher. Det er en
1853
+ // veto, ikke en vaegtning. Et overlay der ikke bliver lukket koster et ekstra
1854
+ // skridt; en lukket konto koster brugeren penge eller adgang.
1855
+ const VETO = [
1856
+ 'account', 'konto', 'subscription', 'abonnement', 'membership', 'medlemskab',
1857
+ 'payment', 'betaling', 'kort', 'card', 'billing', 'faktura', 'invoice',
1858
+ 'delete', 'slet', 'remove', 'fjern', 'erase', 'wipe', 'destroy',
1859
+ 'permanent', 'permanently', 'forever', 'for evigt', 'irreversibl',
1860
+ 'unsubscribe', 'opsig', 'afmeld', 'terminate', 'opheav',
1861
+ 'deactivate', 'deaktiver', 'disable', 'deaktivér',
1862
+ 'sign out', 'log out', 'log ud', 'logout', 'sign-out',
1863
+ 'order', 'ordre', 'purchase', 'koeb', 'køb', 'refund', 'refunder',
1864
+ ];
1865
+ const erFarlig = (tekst) => {
1866
+ const t = (tekst || '').toLowerCase();
1867
+ return VETO.some((v) => t.includes(v));
1868
+ };
1869
+
1425
1870
  const ambiguousTexts = [
1426
1871
  "skip", "cancel", "afvis", "spring over",
1427
1872
  ];
1428
1873
  const xChars = ['×', '✕', '✖', '⨯'];
1429
1874
 
1875
+ // MAALT 21/8: her stod `if (!el || !el.offsetParent && el.tagName !== 'BODY') return false`.
1876
+ // offsetParent er ALTID null for et position:fixed-element — det er ikke en fejl i
1877
+ // browseren, det er definitionen. Saa hele overlay-fjerneren var blind for praecis
1878
+ // den slags elementer som cookie-bannere, samtykke-bjaelker og modaler ER. Et
1879
+ // synligt fixed-banner med <button aria-label="Close"> blev hverken fundet som
1880
+ // overlay eller som luk-knap: dismissed:[], skipped:[], count:0 — tavst intet.
1881
+ //
1882
+ // Rigtig synlighed laeses af layout og stil, ikke af offsetParent.
1430
1883
  const isVisible = (el) => {
1431
- if (!el || !el.offsetParent && el.tagName !== 'BODY') return false;
1884
+ if (!el) return false;
1432
1885
  const rect = el.getBoundingClientRect();
1433
- return rect.width > 0 && rect.height > 0;
1886
+ if (rect.width <= 0 || rect.height <= 0) return false;
1887
+ const st = getComputedStyle(el);
1888
+ if (st.display === 'none' || st.visibility === 'hidden' || st.visibility === 'collapse') return false;
1889
+ if (parseFloat(st.opacity) === 0) return false;
1890
+ return true;
1434
1891
  };
1435
1892
 
1436
1893
  const findCloseAffordance = (overlay, allowAmbiguous) => {
1437
1894
  const all = [...overlay.querySelectorAll('button, [role="button"], a[href="#"], [aria-label]')];
1438
1895
  const allTexts = allowAmbiguous ? [...safeTexts, ...ambiguousTexts] : safeTexts;
1439
1896
 
1440
- // Priority 1: aria-label match (close/dismiss/luk/afvis are always safe)
1897
+ // Priority 1: aria-label match. MAALT 23/8: "always safe" var FORKERT — aria-label
1898
+ // "Close account" indeholder "close", saa knappen blev trykket. Vetoet nedenfor
1899
+ // er derfor det foerste der koeres, foer nogen match overhovedet forsoeges.
1441
1900
  for (const c of all) {
1442
1901
  if (!isVisible(c)) continue;
1443
1902
  const label = (c.getAttribute('aria-label') || '').toLowerCase();
1444
1903
  if (!label) continue;
1904
+ if (erFarlig(label) || erFarlig(c.textContent)) continue; // veto — se listen ovenfor
1445
1905
  if (label.includes('close') || label.includes('dismiss') || label.includes('luk')) {
1446
1906
  return { el: c, method: 'aria-label', label };
1447
1907
  }
@@ -1455,6 +1915,7 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1455
1915
  if (!isVisible(c)) continue;
1456
1916
  const text = (c.textContent || '').trim().toLowerCase();
1457
1917
  if (!text || text.length > 30) continue;
1918
+ if (erFarlig(text) || erFarlig(c.getAttribute('aria-label'))) continue; // veto
1458
1919
  if (allTexts.some(t => text === t || text === t + '!' || text === t + '.')) {
1459
1920
  return { el: c, method: 'text-exact', label: text };
1460
1921
  }
@@ -1464,7 +1925,11 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1464
1925
  if (!isVisible(c)) continue;
1465
1926
  const text = (c.textContent || '').trim().toLowerCase();
1466
1927
  if (!text || text.length > 40) continue;
1467
- if (allTexts.some(t => text.includes(t))) {
1928
+ // MAALT 22/8: contains-passet gjorde "ok" til en delstreng-traeffer, saa
1929
+ // "Book a demo", "Unlock account" og "Cookie settings" blev klikbare — i
1930
+ // DEFAULT-scope. Korte ord maa kun matche eksakt (prioritet 2 ovenfor).
1931
+ if (erFarlig(text) || erFarlig(c.getAttribute('aria-label'))) continue; // veto
1932
+ if (allTexts.filter(t => t.length >= 5).some(t => text.includes(t))) {
1468
1933
  return { el: c, method: 'text-contains', label: text };
1469
1934
  }
1470
1935
  }
@@ -1473,6 +1938,7 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1473
1938
  for (const c of all) {
1474
1939
  if (!isVisible(c)) continue;
1475
1940
  const text = (c.textContent || '').trim();
1941
+ if (erFarlig(c.getAttribute('aria-label'))) continue; // et × med farlig aria-label
1476
1942
  if (xChars.includes(text)) {
1477
1943
  return { el: c, method: 'x-char', label: text };
1478
1944
  }
@@ -1494,6 +1960,21 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1494
1960
  '[class*="banner" i]:not([class*="-hidden"]):not(input):not(button)',
1495
1961
  '[data-testid*="dialog" i]',
1496
1962
  '[data-testid*="modal" i]',
1963
+ // MAALT 22/8 af flowtesten: listen matchede kun paa class, aldrig paa id. Et
1964
+ // helt almindeligt <div id="banner"> blev derfor ALDRIG set som et overlay —
1965
+ // og cookie-bannere skrives lige saa ofte med id som med class. Hullet var
1966
+ // skjult indtil i dag, fordi det strukturelle fixed/sticky-spor fangede dem
1967
+ // alligevel; det spor blev skaaret efter sikkerhedsreview, og saa stod hullet
1968
+ // bart. De samme fem ord, samme regler — bare paa id.
1969
+ //
1970
+ // Det her er IKKE det skaarne spor i ny form: her har sideforfatteren selv
1971
+ // kaldt elementet en dialog/modal/banner. Det er en eksplicit erklaering, ikke
1972
+ // et gaet ud fra placering, saa faren ved delstrengs-matchning gaelder ikke.
1973
+ '[id*="modal" i]:not(input):not(button)',
1974
+ '[id*="overlay" i]:not(input):not(button)',
1975
+ '[id*="popover" i]:not(input):not(button)',
1976
+ '[id*="banner" i]:not(input):not(button)',
1977
+ '[id*="dialog" i]:not(input):not(button)',
1497
1978
  ];
1498
1979
  for (const sel of selectors) {
1499
1980
  try {
@@ -1505,6 +1986,15 @@ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1505
1986
  } catch {}
1506
1987
  }
1507
1988
 
1989
+ // Et strukturelt spor (alt fixed/sticky over 40x20px som overlay-kandidat) blev
1990
+ // proevet 21/8 og SKAARET 22/8 efter sikkerhedsreview: findCloseAffordance matcher
1991
+ // paa delstrenge, saa "Cancel subscription", "Close account" og "Book now" (via "ok")
1992
+ // alle blev klikkbare — paa hver eneste side, og instruktionerne beder agenten kalde
1993
+ // dismiss_overlays foer hvert stoerre skridt. Den maalte fejl var offsetParent-
1994
+ // blindheden i isVisible ovenfor; den er rettet. Sporet var ny adfaerd uden bevist
1995
+ // behov. Genindfoeres kun med eksakt tekstmatch og et krav om luk-affordance som
1996
+ // direkte barn.
1997
+
1508
1998
  for (const overlay of overlays) {
1509
1999
  const role = overlay.getAttribute('role') || (overlay.className || '').split(' ')[0] || 'unknown';
1510
2000
 
@@ -1761,13 +2251,86 @@ async function dropFileOnTarget(tabId, selector, files) {
1761
2251
  }
1762
2252
  }
1763
2253
 
2254
+ // Strategy 2 (v1.27): intercept the NATIVE OS file chooser.
2255
+ //
2256
+ // Sites like Google Ads never put an <input type="file"> in the DOM — clicking
2257
+ // their "choose a file" control opens the OS dialog directly, which no browser
2258
+ // automation can reach. Page.setInterceptFileChooserDialog makes Chrome fire
2259
+ // Page.fileChooserOpened instead of showing that dialog, and the event carries
2260
+ // the backendNodeId of the element that requested it. DOM.setFileInputFiles
2261
+ // accepts a backendNodeId, so we can satisfy the request programmatically.
2262
+ //
2263
+ // Order matters: interception must be armed BEFORE the click that opens the
2264
+ // chooser, otherwise the OS dialog is already up and the event never fires.
2265
+ const chooserResult = await interceptFileChooser(tabId, selector, fileList);
2266
+ if (chooserResult) return chooserResult;
2267
+
1764
2268
  return {
1765
2269
  ok: false,
1766
2270
  error: 'no-file-input-found',
1767
- hint: 'No <input type="file"> found in target subtree, parent, or page. Pure drag-drop zones (without backing input) require synthesizing File objects from disk content via mcp-server, which is not yet implemented. Try selecting a more specific selector, or fall back to manual upload via browser_ask_user.',
2271
+ hint: 'No <input type="file"> found, and the native file-chooser interception did not fire. The trigger element may not open a file dialog at all check the selector.',
1768
2272
  };
1769
2273
  }
1770
2274
 
2275
+ // Arms Page.fileChooserOpened, clicks the trigger, and fulfils the chooser with
2276
+ // the given files. Returns null if no chooser opened (so the caller can fall
2277
+ // through to its own error), or a result object on success/explicit failure.
2278
+ async function interceptFileChooser(tabId, selector, fileList) {
2279
+ try {
2280
+ await debuggerAttach(tabId);
2281
+ await cdpSend(tabId, 'Page.enable', {});
2282
+ await cdpSend(tabId, 'DOM.enable', {});
2283
+ await cdpSend(tabId, 'Page.setInterceptFileChooserDialog', { enabled: true });
2284
+ } catch (e) {
2285
+ return null; // interception unavailable — let caller report the original error
2286
+ }
2287
+
2288
+ try {
2289
+ return await new Promise((resolve) => {
2290
+ let settled = false;
2291
+ const finish = (value) => {
2292
+ if (settled) return;
2293
+ settled = true;
2294
+ chrome.debugger.onEvent.removeListener(listener);
2295
+ clearTimeout(timer);
2296
+ resolve(value);
2297
+ };
2298
+
2299
+ const listener = (source, method, eventParams) => {
2300
+ if (source.tabId !== tabId || method !== 'Page.fileChooserOpened') return;
2301
+ const backendNodeId = eventParams?.backendNodeId;
2302
+ if (!backendNodeId) {
2303
+ finish({ ok: false, error: 'file-chooser-without-node', detail: 'Chrome fired fileChooserOpened but supplied no backendNodeId.' });
2304
+ return;
2305
+ }
2306
+ cdpSend(tabId, 'DOM.setFileInputFiles', { backendNodeId, files: fileList })
2307
+ .then(() => finish({
2308
+ ok: true,
2309
+ method: 'native-chooser-intercepted',
2310
+ files: fileList,
2311
+ mode: eventParams.mode,
2312
+ }))
2313
+ .catch(e => finish({ ok: false, error: 'setFileInputFiles-failed', detail: e?.message || String(e) }));
2314
+ };
2315
+ chrome.debugger.onEvent.addListener(listener);
2316
+
2317
+ // 8s: the click is local, so the chooser opens within a frame or two.
2318
+ // A longer wait would just stall the caller when the element opens no dialog.
2319
+ const timer = setTimeout(() => finish(null), 8000);
2320
+
2321
+ // Click the trigger so the page asks for the chooser. Real mouse events —
2322
+ // a synthetic .click() does not always reach the file-picker code path.
2323
+ (async () => {
2324
+ const el = await resolveElement(tabId, selector);
2325
+ if (!el) { finish({ ok: false, error: 'trigger-not-found', detail: selector }); return; }
2326
+ await debuggerClick(tabId, el.x, el.y);
2327
+ })().catch(e => finish({ ok: false, error: 'trigger-click-failed', detail: e?.message || String(e) }));
2328
+ });
2329
+ } finally {
2330
+ try { await cdpSend(tabId, 'Page.setInterceptFileChooserDialog', { enabled: false }); } catch {}
2331
+ }
2332
+ }
2333
+
1771
2334
  // ── Command Dispatcher ──────────────────────────────────────────────────────
1772
2335
 
1773
2336
  async function dispatch(port, method, params) {
@@ -1825,11 +2388,84 @@ async function dispatch(port, method, params) {
1825
2388
  return { content, url: tab.url, title: tab.title, method: 'debugger' };
1826
2389
  }
1827
2390
 
2391
+ // Read EVERY row of a virtualised list by scrolling its container until the set stops
2392
+ // growing. Outlook, Gmail and most mail/table UIs keep only ~7 rows in the DOM, so a
2393
+ // single get_page_content sees a sliver — this walks the whole list instead.
2394
+ case 'extract_list': {
2395
+ const tab = await getSessionTab(port);
2396
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
2397
+ const rowSel = params.selector;
2398
+ if (!rowSel) throw new Error('extract_list requires `selector` (the repeating row element)');
2399
+ const containerSel = params.container || null;
2400
+ const maxRows = Math.min(params.max_rows || 500, 5000);
2401
+ const stableNeeded = params.stable_rounds || 3;
2402
+ const waitMs = params.wait_ms || 350;
2403
+
2404
+ const seen = new Set();
2405
+ let stable = 0, rounds = 0, atEnd = false;
2406
+ const MAX_ROUNDS = 300; // backstop: a list that never stabilises must not spin forever
2407
+
2408
+ while (seen.size < maxRows && stable < stableNeeded && rounds < MAX_ROUNDS) {
2409
+ rounds++;
2410
+ const r = await safeExecuteScript(tab.id, (rs, cs, keep) => {
2411
+ const rows = Array.from(document.querySelectorAll(rs));
2412
+ const texts = rows
2413
+ .map(el => (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim())
2414
+ .filter(t => t.length > 0);
2415
+
2416
+ // Scroll the row's own scrollable ancestor — scrolling window does nothing when the
2417
+ // list lives in an inner overflow container (the normal case in webmail).
2418
+ let c = cs ? document.querySelector(cs) : null;
2419
+ if (!c && rows.length) {
2420
+ let p = rows[0].parentElement;
2421
+ while (p && p !== document.documentElement) {
2422
+ const s = getComputedStyle(p);
2423
+ if (/(auto|scroll)/.test(s.overflowY) && p.scrollHeight > p.clientHeight + 20) { c = p; break; }
2424
+ p = p.parentElement;
2425
+ }
2426
+ }
2427
+ const step = keep || (c ? c.clientHeight : window.innerHeight) * 0.85;
2428
+ const before = c ? c.scrollTop : window.scrollY;
2429
+ if (c) c.scrollTop = before + step; else window.scrollBy(0, step);
2430
+ const after = c ? c.scrollTop : window.scrollY;
2431
+ const done = c
2432
+ ? c.scrollTop + c.clientHeight >= c.scrollHeight - 4
2433
+ : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4;
2434
+ return { texts, moved: after - before, done, container: !!c };
2435
+ }, [rowSel, containerSel, params.scroll_step || 0]);
2436
+
2437
+ if (r.cspBlocked) throw new Error('extract_list: this page blocks script injection — use screenshots instead');
2438
+ const data = r.result || { texts: [] };
2439
+ const before = seen.size;
2440
+ for (const t of data.texts) seen.add(t);
2441
+ // Two independent stop signals: nothing new appeared, or the container hit its end.
2442
+ if (seen.size === before) stable++; else stable = 0;
2443
+ if (data.done) { atEnd = true; stable++; }
2444
+ if (!data.texts.length && rounds > 2) break; // selector matches nothing — fail fast
2445
+ await new Promise(res => setTimeout(res, waitMs));
2446
+ }
2447
+
2448
+ return {
2449
+ rows: [...seen],
2450
+ count: seen.size,
2451
+ rounds,
2452
+ reached_end: atEnd,
2453
+ truncated: seen.size >= maxRows,
2454
+ };
2455
+ }
2456
+
1828
2457
  case 'screenshot': {
1829
- // getSessionTab(…, true) is focus-NEUTRAL now: it un-minimizes + activates the tab
1830
- // but does NOT steal window focus (FIX-1). Screenshots run constantly, so the common
1831
- // path must never yank Chrome to the foreground.
1832
- const tab = await getSessionTab(port, true);
2458
+ // EKSPERIMENT 21/8: fanen aktiveres IKKE laengere.
2459
+ //
2460
+ // getSessionTab(…, true) stjal ikke VINDUES-fokus (FIX-1), men den kaldte stadig
2461
+ // chrome.tabs.update({active:true}) altsaa et fane-skift INDE i vinduet. Sidder
2462
+ // brugeren i det samme Chrome-vindue paa sin egen fane, bliver den revet vaek hver
2463
+ // eneste gang agenten tager et billede. Og billeder tages konstant.
2464
+ //
2465
+ // Aktiveringen er formentlig unoedvendig: CDP Page.captureScreenshot nedenfor
2466
+ // fotograferer en fane der ikke er forrest. Den okkluderede sidste-udvej loefter
2467
+ // stadig vinduet hvis CDP virkelig ikke kan producere en frame.
2468
+ const tab = await getSessionTab(port, false);
1833
2469
  if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:')) {
1834
2470
  throw new Error(`Cannot screenshot ${tab.url.split(':')[0]}: pages — navigate to a real page first`);
1835
2471
  }
@@ -1887,6 +2523,12 @@ async function dispatch(port, method, params) {
1887
2523
 
1888
2524
  case 'execute_script': {
1889
2525
  // v1.22.2 (DIAGNOSTIC): Try scripting paths but log all errors so we can see WHY they fail
2526
+ // v1.26: accept `script` as alias for `code` — the historic param-name mismatch caused
2527
+ // silent "unserializable"/undefined failures that read as "execute_script is broken".
2528
+ if (params.code == null && typeof params.script === 'string') params.code = params.script;
2529
+ if (typeof params.code !== 'string' || !params.code.trim()) {
2530
+ return { ok: false, error: 'Missing code. Pass a JavaScript EXPRESSION in `code` (e.g. an IIFE: (() => {...; return x;})()). `return ...` at top level is invalid — the handler wraps code in parentheses.' };
2531
+ }
1890
2532
  const tab = await getSessionTab(port);
1891
2533
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot execute scripts on chrome:// pages');
1892
2534
 
@@ -2003,14 +2645,48 @@ async function dispatch(port, method, params) {
2003
2645
  try {
2004
2646
  const el = await resolveElement(tab.id, params.selector);
2005
2647
  if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
2648
+ // Elementet findes, men har ingen udstraekning — at klikke ville ramme (0,0),
2649
+ // altsaa et HELT andet element end det der blev bedt om. Sig det i stedet.
2650
+ if (el.hidden) {
2651
+ return {
2652
+ ok: false,
2653
+ error: 'Element fundet men ikke synligt (0x0) — klik ville ramme sidens hjoerne: ' + params.selector,
2654
+ hidden: true,
2655
+ tag: el.tag,
2656
+ };
2657
+ }
2006
2658
 
2007
2659
  // Primary path: debugger mouse events (isTrusted=true, works on React/Angular SPAs)
2008
- await debuggerClick(tab.id, el.x, el.y);
2009
- return { ok: true, method: el.method || 'debugger', tag: el.tag, text: el.text };
2660
+ const clickResult = await debuggerClick(tab.id, el.x, el.y);
2661
+ return {
2662
+ ok: true,
2663
+ method: el.method || 'debugger',
2664
+ tag: el.tag,
2665
+ text: el.text,
2666
+ // MAALT 21/8: `landed` blev allerede beregnet inde i debuggerClick og smidt vaek,
2667
+ // saa `click` svarede ok:true selv naar siden slet ikke reagerede. Nu foelger den med:
2668
+ // landed=false betyder "eventet blev sendt, men intet handler tog imod det".
2669
+ ...(clickResult || {}),
2670
+ };
2010
2671
  } catch (e) {
2011
2672
  // Fallback: synthetic click via chrome.scripting for anti-automation sites
2012
2673
  // (Apple ASC etc.) OR user-blocked-debugger scenarios.
2013
- if (/Debugger detached/.test(e?.message || '')) {
2674
+ //
2675
+ // MÅLT 29/7: betingelsen var kun /Debugger detached/, men den HYPPIGSTE fejl hedder
2676
+ // "Debugger attach failed after 3 attempts" (kastes l.298) — altså når Chrome nægter
2677
+ // at koble debuggeren på overhovedet. De to strenge ligner hinanden og betyder næsten
2678
+ // det samme, men regexet ramte kun den ene, så fallbacken fyrede aldrig i det tilfælde
2679
+ // den var skrevet til: "user-blocked-debugger scenarios" står ordret i kommentaren
2680
+ // ovenfor, og det var netop dét den ikke dækkede.
2681
+ //
2682
+ // Konsekvens i praksis: klikker brugeren Cancel på Chromes debugger-banner ÉN gang,
2683
+ // husker Chrome det på tværs af extension-reloads, og hvert eneste klik fejler
2684
+ // permanent — selvom scriptingClick ville have virket hele tiden. Den bruger `func:`
2685
+ // og ikke en kode-streng, så den rammes ikke af sidens CSP.
2686
+ //
2687
+ // Prisen ved fallbacken er at klikket mister isTrusted=true. Det tjekker de færreste
2688
+ // sider, og et klik der virker på 95% af nettet slår et klik der aldrig virker.
2689
+ if (/Debugger detached|Debugger attach failed|not attached/i.test(e?.message || '')) {
2014
2690
  const r = await scriptingClick(tab.id, params.selector);
2015
2691
  if (r.ok) return { ok: true, method: 'scripting-fallback', tag: r.tag };
2016
2692
  }
@@ -2148,6 +2824,11 @@ async function dispatch(port, method, params) {
2148
2824
  const r = await setCombobox(tab.id, params.selector, values, {
2149
2825
  multi: !!params.multi,
2150
2826
  query_chars: params.query_chars,
2827
+ // MAALT 22/8: wait_ms blev tavst kasseret her. Skemaet lover parameteren, og
2828
+ // setCombobox laeser opts.wait_ms — men handleren videregav den ikke, saa
2829
+ // ventetiden stod altid paa standarden 3000 ms. En agent der bad om laengere
2830
+ // tid til en langsom liste fik den ikke, og fik ingen besked om det.
2831
+ wait_ms: params.wait_ms,
2151
2832
  });
2152
2833
  const visibleErrors = r.ok ? [] : await collectVisibleErrors(tab.id, params.selector);
2153
2834
  return r.ok ? r : { ...r, visible_errors: visibleErrors };
@@ -2156,7 +2837,8 @@ async function dispatch(port, method, params) {
2156
2837
  case 'drop_file': {
2157
2838
  const tab = await getSessionTab(port);
2158
2839
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2159
- const files = Array.isArray(params.files) ? params.files : [params.files || params.file];
2840
+ const files = Array.isArray(params.files) ? params.files
2841
+ : [params.files || params.file || params.file_path].filter(Boolean);
2160
2842
  if (!files[0]) return { ok: false, error: 'files or file required' };
2161
2843
  return await dropFileOnTarget(tab.id, params.selector || 'body', files);
2162
2844
  }
@@ -2187,8 +2869,9 @@ async function dispatch(port, method, params) {
2187
2869
  }
2188
2870
 
2189
2871
  case 'press_key': {
2190
- // v1.22: activate tab so key-event lands in foreground (otherwise Chrome routes to active tab)
2191
- const tab = await getSessionTab(port, true);
2872
+ // EKSPERIMENT 21/8: aktiverer IKKE fanen. Kommentaren her sagde at Chrome ellers
2873
+ // sender tastetrykket til den aktive fane — det maales nu i stedet for at antages.
2874
+ const tab = await getSessionTab(port, false);
2192
2875
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2193
2876
  const key = params.key; // e.g. "Enter", "Tab", "Escape", "ArrowDown"
2194
2877
  const modifiers = (params.ctrl ? 2 : 0) | (params.alt ? 1 : 0) | (params.shift ? 8 : 0) | (params.meta ? 4 : 0);
@@ -2267,6 +2950,65 @@ async function dispatch(port, method, params) {
2267
2950
  return { ok: true, scrolled: { x: dx, y: dy }, method: 'mouseWheel-stepped' };
2268
2951
  }
2269
2952
 
2953
+ // ── v1.26 "superior" tools ──────────────────────────────────────────────
2954
+ // to the MCP server — only lengths and shape booleans. Born from the 2026-07-27
2955
+ // Azure-secret night: the agent must be able to move a credential from page to
2956
+ // field without the value ever entering the LLM context or transcript.
2957
+
2958
+ case 'double_click': {
2959
+ const tab = await getSessionTab(port);
2960
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2961
+ const el = await resolveElement(tab.id, params.selector);
2962
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
2963
+ await debuggerAttach(tab.id);
2964
+ const { x, y } = el;
2965
+ await cdpSend(tab.id, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
2966
+ await new Promise(r => setTimeout(r, 30));
2967
+ // Proper dblclick: two press/release pairs with escalating clickCount.
2968
+ await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 });
2969
+ await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 });
2970
+ await new Promise(r => setTimeout(r, 40));
2971
+ await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 2 });
2972
+ await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 2 });
2973
+ return { ok: true, double_clicked: true, tag: el.tag, text: el.text };
2974
+ }
2975
+
2976
+ case 'right_click': {
2977
+ const tab = await getSessionTab(port);
2978
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2979
+ const el = await resolveElement(tab.id, params.selector);
2980
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
2981
+ await debuggerAttach(tab.id);
2982
+ const { x, y } = el;
2983
+ await cdpSend(tab.id, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
2984
+ await new Promise(r => setTimeout(r, 30));
2985
+ await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'right', buttons: 2, clickCount: 1 });
2986
+ await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'right', buttons: 0, clickCount: 1 });
2987
+ return { ok: true, right_clicked: true, tag: el.tag, text: el.text, note: 'contextmenu event fired; native Chrome menu does not open via CDP — page-level menus (OWA, web apps) do' };
2988
+ }
2989
+
2990
+ case 'click_xy': {
2991
+ // Raw coordinate click — the escape hatch for custom widgets whose buttons
2992
+ // resist every selector strategy (Azure portal dialogs, KO-bound divs).
2993
+ // Coordinates come from the caller's own screenshot analysis.
2994
+ const tab = await getSessionTab(port);
2995
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2996
+ if (typeof params.x !== 'number' || typeof params.y !== 'number') {
2997
+ return { ok: false, error: 'x and y (numbers, CSS pixels in viewport) are required' };
2998
+ }
2999
+ await debuggerClick(tab.id, params.x, params.y);
3000
+ return { ok: true, clicked_at: { x: params.x, y: params.y } };
3001
+ }
3002
+
3003
+ case 'reattach_debugger': {
3004
+ // Ghost-attach recovery without full extension reload: force detach + fresh attach.
3005
+ const tab = await getSessionTab(port);
3006
+ try { await chrome.debugger.detach({ tabId: tab.id }); } catch {}
3007
+ await new Promise(r => setTimeout(r, 150));
3008
+ await debuggerAttach(tab.id);
3009
+ return { ok: true, reattached: true, tab_id: tab.id };
3010
+ }
3011
+
2270
3012
  case 'hover': {
2271
3013
  const tab = await getSessionTab(port);
2272
3014
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
@@ -2297,21 +3039,45 @@ async function dispatch(port, method, params) {
2297
3039
  })()
2298
3040
  `);
2299
3041
 
3042
+ // `value` og `label` accepteres som alias for `option`. Uden dem gav et forkert
3043
+ // navn "undefined" som soegetekst — og vaerktoejet svarede alligevel ok:true.
3044
+ const oensket = params.option ?? params.value ?? params.label;
3045
+ if (typeof oensket !== 'string' || !oensket) {
3046
+ return { ok: false, error: 'Manglende `option` (teksten eller vaerdien paa den mulighed der skal vaelges).' };
3047
+ }
3048
+
2300
3049
  if (isNativeSelect) {
2301
- // Native <select>set value directly
2302
- await debuggerEval(tab.id, `
3050
+ // MAALT 21/8: her blev resultatet af evalueringen `return !!opt` — kastet vaek,
3051
+ // og handleren svarede ubetinget ok:true. Blev muligheden ikke fundet, skete der
3052
+ // INTET, og svaret sagde stadig at det var lykkedes. Samme fejlklasse som klikket
3053
+ // der svarede ok:true uden at siden reagerede. Nu laeses svaret, og der laeses
3054
+ // TILBAGE fra feltet bagefter, saa "valgt" betyder at vaerdien faktisk staar der.
3055
+ const valg = await debuggerEval(tab.id, `
2303
3056
  (function() {
2304
3057
  const sel = document.querySelector(${JSON.stringify(params.selector)});
2305
- const opt = Array.from(sel.options).find(o => o.text.includes(${JSON.stringify(params.option)}) || o.value === ${JSON.stringify(params.option)});
2306
- if (opt) {
2307
- sel.value = opt.value;
2308
- sel.dispatchEvent(new Event('change', { bubbles: true }));
2309
- sel.dispatchEvent(new Event('input', { bubbles: true }));
3058
+ if (!sel) return JSON.stringify({ found: false, error: 'select ikke fundet' });
3059
+ const oensket = ${JSON.stringify(oensket)};
3060
+ const opt = Array.from(sel.options).find(o => o.value === oensket)
3061
+ || Array.from(sel.options).find(o => o.text.trim() === oensket)
3062
+ || Array.from(sel.options).find(o => o.text.includes(oensket));
3063
+ if (!opt) {
3064
+ return JSON.stringify({ found: false, error: 'Ingen mulighed matchede: ' + oensket,
3065
+ available: Array.from(sel.options).map(o => o.text.trim()).slice(0, 25) });
2310
3066
  }
2311
- return !!opt;
3067
+ sel.value = opt.value;
3068
+ sel.dispatchEvent(new Event('input', { bubbles: true }));
3069
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
3070
+ return JSON.stringify({ found: true, wanted: opt.value, actual: sel.value, text: opt.text.trim() });
2312
3071
  })()
2313
3072
  `);
2314
- return { ok: true, type: 'native_select' };
3073
+ let r; try { r = JSON.parse(valg); } catch { r = null; }
3074
+ if (!r) return { ok: false, type: 'native_select', error: 'Kunne ikke laese resultatet af valget' };
3075
+ if (!r.found) return { ok: false, type: 'native_select', error: r.error, available: r.available };
3076
+ if (r.actual !== r.wanted) {
3077
+ // Reagerede siden ved at rulle valget tilbage (React-styret select), skal det siges.
3078
+ return { ok: false, type: 'native_select', error: `Valget blev rullet tilbage: satte "${r.wanted}", feltet staar paa "${r.actual}"` };
3079
+ }
3080
+ return { ok: true, type: 'native_select', selected: r.text, value: r.actual };
2315
3081
  }
2316
3082
 
2317
3083
  // Custom dropdown (Angular Material, React Select, etc.)
@@ -2324,57 +3090,75 @@ async function dispatch(port, method, params) {
2324
3090
  await new Promise(r => setTimeout(r, params.wait || 300));
2325
3091
 
2326
3092
  // Step 3: Find and click the option by text
2327
- const option = await resolveElement(tab.id, `text=${params.option}`);
2328
- if (!option) return { ok: false, error: 'Option not found: ' + params.option };
2329
- await debuggerClick(tab.id, option.x, option.y);
2330
-
2331
- return { ok: true, type: 'custom_dropdown', selected: params.option };
3093
+ const option = await resolveElement(tab.id, `text=${oensket}`);
3094
+ if (!option) return { ok: false, error: 'Option not found: ' + oensket };
3095
+ const valgKlik = await debuggerClick(tab.id, option.x, option.y);
3096
+
3097
+ // MAALT 22/8 ved review: aerlighedsfixet blev kun anvendt paa native-grenen
3098
+ // ovenfor. Her stod stadig `return { ok: true }` ubetinget, selv om resultatet
3099
+ // af klikket var beregnet og smidt vaek — praecis den fejl der blev lukket to
3100
+ // gange andre steder samme dag. En brugerdefineret dropdown hvor klikket ikke
3101
+ // blev taget imod, meldte altsaa stadig succes.
3102
+ // MAALT 22/8 (tredje gang samme fejlklasse): `ok: true` stod hardkodet, og `landed`
3103
+ // blev blot spredt ind ved siden af. En dropdown hvor klikket ikke blev taget imod
3104
+ // svarede altsaa {ok:true, landed:false} — og agenten laeser ok. Beskrivelsen lover
3105
+ // ordret "it never reports success without the field actually changing".
3106
+ return {
3107
+ ...(valgKlik || {}),
3108
+ ok: valgKlik?.landed !== false,
3109
+ type: 'custom_dropdown',
3110
+ selected: oensket,
3111
+ ...(valgKlik?.landed === false
3112
+ ? { error: 'Klikket paa muligheden blev ikke taget imod af siden: ' + oensket }
3113
+ : {}),
3114
+ };
2332
3115
  }
2333
3116
 
2334
3117
  case 'handle_dialog': {
2335
- // Auto-handle JS alert/confirm/prompt dialogs
2336
- // Must be set up BEFORE the dialog appears
2337
3118
  const tab = await getSessionTab(port);
2338
3119
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
2339
- const action = params.action || 'accept'; // accept, dismiss
3120
+ const action = params.action === 'dismiss' ? 'dismiss' : 'accept';
2340
3121
  const promptText = params.text || '';
3122
+ const vent = params.wait === true; // gammel, blokerende adfaerd
3123
+ const levetid = params.timeout || 60000;
2341
3124
 
2342
3125
  await debuggerAttach(tab.id);
2343
- try {
2344
- // Enable page events to catch dialogs
2345
- await cdpSend(tab.id, 'Page.enable', {});
3126
+ await cdpSend(tab.id, 'Page.enable', {});
3127
+ afvaebnDialog(tab.id, 'en ny armering overtog denne fane'); // kun én ad gangen
3128
+
3129
+ let opfyld;
3130
+ const svar = new Promise((resolve) => { opfyld = resolve; });
3131
+
3132
+ const listener = (source, method, eventParams) => {
3133
+ if (source.tabId !== tab.id || method !== 'Page.javascriptDialogOpening') return;
3134
+ afvaebnDialog(tab.id); // uden grund: vi svarer selv lige nedenfor
3135
+ cdpSend(tab.id, 'Page.handleJavaScriptDialog', {
3136
+ accept: action === 'accept',
3137
+ promptText,
3138
+ })
3139
+ .then(() => opfyld({ ok: true, dialog_type: eventParams.type, message: eventParams.message, action }))
3140
+ .catch((e) => opfyld({ ok: false, error: e.message }));
3141
+ };
2346
3142
 
2347
- // Wait for dialog to appear (or handle existing one)
2348
- const result = await new Promise((resolve) => {
2349
- const timeout = setTimeout(() => {
2350
- chrome.debugger.onEvent.removeListener(listener);
2351
- resolve({ ok: false, error: 'No dialog appeared within timeout' });
2352
- }, params.timeout || 10000);
3143
+ const timer = setTimeout(() => {
3144
+ afvaebnDialog(tab.id);
3145
+ opfyld({ ok: false, error: `Ingen dialog dukkede op inden for ${levetid} ms` });
3146
+ }, levetid);
2353
3147
 
2354
- const listener = (source, method, eventParams) => {
2355
- if (source.tabId !== tab.id || method !== 'Page.javascriptDialogOpening') return;
2356
- chrome.debugger.onEvent.removeListener(listener);
2357
- clearTimeout(timeout);
2358
-
2359
- cdpSend(tab.id, 'Page.handleJavaScriptDialog', {
2360
- accept: action === 'accept',
2361
- promptText: promptText,
2362
- }).then(() => {
2363
- resolve({
2364
- ok: true,
2365
- dialog_type: eventParams.type,
2366
- message: eventParams.message,
2367
- action,
2368
- });
2369
- }).catch(e => resolve({ ok: false, error: e.message }));
2370
- };
2371
- chrome.debugger.onEvent.addListener(listener);
2372
- });
3148
+ armeredeDialoger.set(tab.id, { listener, timer, action, opfyld });
3149
+ dialogLoefter.set(tab.id, svar);
3150
+ chrome.debugger.onEvent.addListener(listener);
2373
3151
 
2374
- return result;
2375
- } finally {
2376
- await debuggerDetach(tab.id);
2377
- }
3152
+ if (vent) return await svar;
3153
+
3154
+ // Armeret. Debuggeren bliver siddende — frakobler vi her, doer lytteren med den.
3155
+ return {
3156
+ ok: true,
3157
+ armed: true,
3158
+ action,
3159
+ expires_in_ms: levetid,
3160
+ note: 'Naeste dialog paa denne fane haandteres automatisk. Klik nu paa det der aabner den.',
3161
+ };
2378
3162
  }
2379
3163
 
2380
3164
  case 'wait_for_network': {
@@ -2700,7 +3484,24 @@ async function dispatch(port, method, params) {
2700
3484
  setTimeout(() => { if (document.getElementById('a360-overlay')) { overlay.remove(); resolve({ acknowledged: false, action: 'timeout', values: {} }); } }, timeout);
2701
3485
  });
2702
3486
  },
2703
- args: [params.message, params.title, fields, hasFields, timeout, session.label],
3487
+ // MAALT 21/8: her stod `params.title` raat. Skemaet siger at title er VALGFRI
3488
+ // med standarden "Agent360 — Action Required", men udelades den, er vaerdien
3489
+ // undefined — og chrome.scripting.executeScript afviser hele kaldet med
3490
+ // "Error at property 'args': Error at index 1: Value is unserializable".
3491
+ // Altsaa styrtede human-in-the-loop-vaerktoejet hver gang en agent fulgte sit
3492
+ // eget skema. Det blev aldrig fanget, fordi ask_user stod som "springes over"
3493
+ // i flowtesten — den eneste der kunne have set det.
3494
+ //
3495
+ // Alle argumenter tvinges nu til serialiserbare vaerdier, og standarden
3496
+ // anvendes der hvor den er lovet.
3497
+ args: [
3498
+ String(params.message ?? ''),
3499
+ String(params.title ?? 'Agent360 — Action Required'),
3500
+ Array.isArray(fields) ? fields : [],
3501
+ Boolean(hasFields),
3502
+ Number(timeout) || 120000,
3503
+ String(session.label ?? 'Claude'),
3504
+ ],
2704
3505
  world: 'MAIN',
2705
3506
  });
2706
3507
 
@@ -2721,13 +3522,35 @@ async function dispatch(port, method, params) {
2721
3522
  return { error: `Frame ${frameIndex} not found. Available: ${frames?.length || 0} frames`, frames: frames?.map((f, i) => ({ index: i, url: f.url })) };
2722
3523
  }
2723
3524
  const frameId = frames[frameIndex].frameId;
3525
+ // MAALT 21/8: her stod `func: new Function('return (' + code + ')')`. Den byggede
3526
+ // funktionen i SERVICE-WORKEREN, hvor udvidelsens egen CSP forbyder eval — saa
3527
+ // vaerktoejet fejlede paa hver eneste side, ogsaa med koden '1+1':
3528
+ // "Evaluating a string as JavaScript violates ... 'unsafe-eval' is not allowed".
3529
+ // execute_script loeser det samme problem korrekt: send koden med som ARGUMENT og
3530
+ // byg funktionen INDE i den injicerede func, hvor sidens egen CSP gaelder. Samme
3531
+ // vej her.
3532
+ //
3533
+ // Og som i execute_script (v1.26): accepter `script` som alias for `code`. Samme
3534
+ // navne-uoverensstemmelse har foer faaet vaerktoejer til at se brudte ud i tavshed.
3535
+ if (params.code == null && typeof params.script === 'string') params.code = params.script;
2724
3536
  const code = params.code || 'document.body.innerText.slice(0, 5000)';
2725
3537
  const [result] = await chrome.scripting.executeScript({
2726
3538
  target: { tabId: tab.id, frameIds: [frameId] },
2727
- func: new Function('return (' + code + ')'),
2728
3539
  world: 'MAIN',
3540
+ args: [code],
3541
+ func: (codeStr) => {
3542
+ try {
3543
+ return { __ok: true, value: new Function('return (' + codeStr + ')')() };
3544
+ } catch (e) {
3545
+ return { __scriptingError: true, message: String(e?.message || e) };
3546
+ }
3547
+ },
2729
3548
  });
2730
- return { result: result.result, frame_url: frames[frameIndex].url };
3549
+ const r = result?.result;
3550
+ if (r && r.__scriptingError) {
3551
+ return { ok: false, error: r.message, frame_url: frames[frameIndex].url };
3552
+ }
3553
+ return { result: r?.value, frame_url: frames[frameIndex].url };
2731
3554
  }
2732
3555
 
2733
3556
  case 'list_frames': {
@@ -2754,9 +3577,24 @@ async function dispatch(port, method, params) {
2754
3577
  throw new Error(`Tab ${params.tab_id} does not belong to this session (${session.label})`);
2755
3578
  }
2756
3579
  const tab = await chrome.tabs.update(params.tab_id, { active: true });
3580
+ // Gør ogsaa VINDUET forrest. Uden det bliver fanen aktiv inde i sit vindue —
3581
+ // document.hasFocus() bliver sand — men document.visibilityState forbliver
3582
+ // 'hidden' fordi vinduet ligger bagved. Chrome struber timere i skjulte
3583
+ // faner, saa Angular-apps (Google Ads, GA4, Search Console) renderer aldrig
3584
+ // faerdigt: man laeser en halvt bygget side og drager forkerte konklusioner.
3585
+ // Kostede to opgaver og en forkert konklusion 31/8-2026.
3586
+ let vinduesFokus = null;
3587
+ try {
3588
+ await chrome.windows.update(tab.windowId, { focused: true });
3589
+ vinduesFokus = true;
3590
+ } catch (e) {
3591
+ // Vinduet kan vaere lukket eller paa et andet Space. Fanen er stadig
3592
+ // aktiv; vi siger bare aerligt at synligheden ikke kunne sikres.
3593
+ vinduesFokus = false;
3594
+ }
2757
3595
  session.activeTabId = tab.id;
2758
3596
  persistSessions();
2759
- return { id: tab.id, url: tab.url, title: tab.title };
3597
+ return { id: tab.id, url: tab.url, title: tab.title, windowFocused: vinduesFokus };
2760
3598
  }
2761
3599
 
2762
3600
  case 'close_tab': {
@@ -2765,6 +3603,9 @@ async function dispatch(port, method, params) {
2765
3603
  if (!session.tabIds.has(tabId)) {
2766
3604
  throw new Error(`Tab ${tabId} does not belong to this session (${session.label})`);
2767
3605
  }
3606
+ // Maerk lukningen som agentens egen. Ellers laeser onRemoved den tomme session som
3607
+ // "brugeren er faerdig" og lukker serveren ned midt i samtalen (MAALT 22/8).
3608
+ agentLukkedeFaner.add(tabId);
2768
3609
  await chrome.tabs.remove(tabId);
2769
3610
  session.tabIds.delete(tabId);
2770
3611
  if (session.activeTabId === tabId) session.activeTabId = null;
@@ -2828,8 +3669,19 @@ async function dispatch(port, method, params) {
2828
3669
  return info;
2829
3670
  }
2830
3671
 
2831
- // Get the DOM node ID for the file input
2832
- const { result: docResult } = await cdpSend(tab.id, 'DOM.getDocument', {});
3672
+ // Get the DOM node ID for the file input.
3673
+ //
3674
+ // MAALT 21/8: her stod `const { result: docResult } = await cdpSend(...)`.
3675
+ // Runtime.evaluate ovenfor svarer {result:{...}}, men DOM.getDocument svarer
3676
+ // {root:{...}} — moenstret var kopieret fra det ene kald til det andet. Saa
3677
+ // docResult var undefined, og vaerktoejet doede paa
3678
+ // "Cannot read properties of undefined (reading 'root')" ved HVERT eneste kald.
3679
+ // browser_upload_file kunne ikke uploade en fil paa nogen side overhovedet.
3680
+ const docResult = await cdpSend(tab.id, 'DOM.getDocument', {});
3681
+ if (!docResult?.root?.nodeId) {
3682
+ await debuggerDetach(tab.id);
3683
+ return { ok: false, error: 'DOM.getDocument gav intet rod-element' };
3684
+ }
2833
3685
  const { nodeId } = await cdpSend(tab.id, 'DOM.querySelector', {
2834
3686
  nodeId: docResult.root.nodeId,
2835
3687
  selector: selector,
@@ -2840,8 +3692,16 @@ async function dispatch(port, method, params) {
2840
3692
  return { found: false, error: 'Could not get DOM node for file input' };
2841
3693
  }
2842
3694
 
2843
- // Set files on the input using CDP
2844
- const files = Array.isArray(params.files) ? params.files : [params.files || params.file];
3695
+ // Set files on the input using CDP.
3696
+ // `file_path` accepteres som alias for `file`/`files` praecis samme navne-faelde
3697
+ // som execute_script fik lukket i v1.26. Et forkert navn gav [undefined] og en
3698
+ // upload der saa ud til at lykkes.
3699
+ const files = Array.isArray(params.files) ? params.files
3700
+ : [params.files || params.file || params.file_path].filter(Boolean);
3701
+ if (!files.length) {
3702
+ await debuggerDetach(tab.id);
3703
+ return { ok: false, error: 'Ingen fil angivet. Brug `files` (array) eller `file` (enkelt sti).' };
3704
+ }
2845
3705
  await cdpSend(tab.id, 'DOM.setFileInputFiles', {
2846
3706
  nodeId: nodeId,
2847
3707
  files: files,
@@ -2975,10 +3835,10 @@ async function clickRecaptchaCheckbox(tabId) {
2975
3835
  type: 'mouseMoved', x: pos.x, y: pos.y,
2976
3836
  });
2977
3837
  await new Promise(r => setTimeout(r, 100 + Math.random() * 200));
2978
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
3838
+ await dispatchTaalmodigt(tabId, {
2979
3839
  type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
2980
3840
  });
2981
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
3841
+ await dispatchTaalmodigt(tabId, {
2982
3842
  type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
2983
3843
  });
2984
3844
  await debuggerDetach(tabId);
@@ -3039,10 +3899,10 @@ async function clickCaptchaGridCells(tabId, cells) {
3039
3899
  type: 'mouseMoved', x: ox, y: oy,
3040
3900
  });
3041
3901
  await new Promise(r => setTimeout(r, 150 + Math.random() * 300));
3042
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
3902
+ await dispatchTaalmodigt(tabId, {
3043
3903
  type: 'mousePressed', x: ox, y: oy, button: 'left', clickCount: 1,
3044
3904
  });
3045
- await cdpSend(tabId, 'Input.dispatchMouseEvent', {
3905
+ await dispatchTaalmodigt(tabId, {
3046
3906
  type: 'mouseReleased', x: ox, y: oy, button: 'left', clickCount: 1,
3047
3907
  });
3048
3908
  await new Promise(r => setTimeout(r, 200 + Math.random() * 400));
@@ -3066,11 +3926,73 @@ async function clickCaptchaGridCells(tabId, cells) {
3066
3926
  ensureOffscreen().catch(console.error);
3067
3927
 
3068
3928
  chrome.runtime.onStartup.addListener(() => ensureOffscreen().catch(console.error));
3069
- chrome.runtime.onInstalled.addListener(() => ensureOffscreen().catch(console.error));
3929
+ // onInstalled fyrer ved installation, opdatering OG ved "Genindlaes" paa
3930
+ // chrome://extensions. I alle tre tilfaelde er koden aendret pr. definition, saa en
3931
+ // overlevende bro er per definition forældet — uanset hvor villigt den svarer paa ping.
3932
+ // MAALT 22/8: uden tvangen slog en genindlaesning aldrig igennem til broen, og
3933
+ // udvikling krævede en fuld genstart af Chrome hver gang.
3934
+ chrome.runtime.onInstalled.addListener(async () => {
3935
+ try {
3936
+ if (await chrome.offscreen.hasDocument()) await chrome.offscreen.closeDocument();
3937
+ } catch (e) {
3938
+ console.warn('[BG] kunne ikke lukke broen ved genindlaesning:', e?.message || e);
3939
+ }
3940
+ await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
3941
+ ensureOffscreen().catch(console.error);
3942
+ });
3943
+
3944
+ // Hjerteslag der genskaber offscreen-dokumentet hvis Chrome har ryddet det.
3945
+ //
3946
+ // FEJL RETTET 19/8: alarmen blev oprettet paa oeverste niveau ved HVER
3947
+ // service-worker-opstart. chrome.alarms.create() med et navn der allerede
3948
+ // findes NULSTILLER nedtaellingen — saa hvis workeren vaagnede oftere end
3949
+ // hvert minut (hvilket den goer ved tab-events, beskeder, navigation),
3950
+ // naaede alarmen aldrig at fyre. Resultat: offscreen-dokumentet doede, intet
3951
+ // genskabte det, og forbindelsen til MCP-serveren kom aldrig tilbage foer
3952
+ // nogen genindlaeste extensionen i haanden.
3953
+ //
3954
+ // Nu oprettes den kun hvis den ikke findes, saa nedtaellingen faar lov at
3955
+ // loebe faerdig.
3956
+ chrome.alarms.get('ensure-offscreen', (eksisterende) => {
3957
+ if (!eksisterende) {
3958
+ chrome.alarms.create('ensure-offscreen', {
3959
+ periodInMinutes: 1,
3960
+ delayInMinutes: 1,
3961
+ });
3962
+ }
3963
+ });
3070
3964
 
3071
- chrome.alarms.create('ensure-offscreen', { periodInMinutes: 1 });
3072
3965
  chrome.alarms.onAlarm.addListener((alarm) => {
3073
3966
  if (alarm.name === 'ensure-offscreen') {
3074
3967
  ensureOffscreen().catch(console.error);
3075
3968
  }
3969
+ if (alarm.name.startsWith('frigiv-')) {
3970
+ const port = Number(alarm.name.slice('frigiv-'.length));
3971
+ // ── Hvorfor lageret laeses DIREKTE og ikke via restoreSessions() ─────────
3972
+ //
3973
+ // FUNDET AF REVIEW 7/9. Her stod `restoreSessions().then(...)`, og kommentaren
3974
+ // sagde at det var vaernet mod en genstartet service-worker. Den gjorde det
3975
+ // modsatte: `restoreSessions()` gendanner kun sessioner der har GYLDIGE FANER
3976
+ // (`if (validTabIds.size > 0)`) — og en tom session er praecis den her alarm
3977
+ // handler om. En MV3-worker suspenderes efter ~30 sekunder; fristen er paa fem
3978
+ // minutter, saa workeren er naesten altid frisk naar alarmen fyrer. Sessionen
3979
+ // blev derfor aldrig fundet, `if (!s) return` ramte, og porten blev holdt til
3980
+ // 4-timers-tomgangen — altsaa praecis den fejl frigivelsen skulle fjerne.
3981
+ //
3982
+ // restoreSessions() har god grund til ikke at genoplive doede sessioner (andre
3983
+ // kaldere vil ikke arve faner der ikke findes). Derfor rettes det HER.
3984
+ (async () => {
3985
+ const iHukommelsen = sessions.get(port);
3986
+ if (iHukommelsen) {
3987
+ if (iHukommelsen.tabIds.size > 0) return; // den arbejder igen — lad den vaere
3988
+ } else {
3989
+ const { sessions: gemte } = await chrome.storage.local.get({ sessions: {} });
3990
+ const gemt = gemte[String(port)];
3991
+ if (!gemt) return; // sessionen er reelt vaek
3992
+ if ((gemt.tabIds || []).length > 0) return; // den arbejder igen
3993
+ }
3994
+ frivilligtFrigivet.add(port);
3995
+ chrome.runtime.sendMessage({ type: 'terminate_mcp_session', port }).catch(() => {});
3996
+ })().catch(() => {});
3997
+ }
3076
3998
  });