@agent360/browser-mcp 1.29.0 → 1.29.1

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.
@@ -79,7 +79,15 @@ function getSession(port, pid) {
79
79
  // samtidige sessioner heller ikke kan faa samme farve.
80
80
  const brugte = new Set([...sessions.values()].map((s) => s.nummer).filter((n) => typeof n === 'number'));
81
81
  let nummer = 1;
82
- while (brugte.has(nummer)) nummer++;
82
+ // Havde denne chat en plads foer den slap sin port, og er den stadig ledig, saa faar
83
+ // den sin egen tilbage — se pladsPrPid. Ellers laveste ledige, som foer.
84
+ const husket = typeof pid === 'number' ? pladsPrPid.get(pid) : undefined;
85
+ if (typeof husket === 'number' && !brugte.has(husket)) {
86
+ nummer = husket;
87
+ } else {
88
+ while (brugte.has(nummer)) nummer++;
89
+ }
90
+ husketPlads(pid, nummer);
83
91
 
84
92
  sessions.set(port, {
85
93
  tabIds: new Set(),
@@ -93,7 +101,7 @@ function getSession(port, pid) {
93
101
  }
94
102
  const s = sessions.get(port);
95
103
  // 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;
104
+ if (s.pid == null && typeof pid === 'number') { s.pid = pid; husketPlads(pid, s.nummer); }
97
105
  return s;
98
106
  }
99
107
 
@@ -260,6 +268,9 @@ async function releaseSession(port) {
260
268
  //
261
269
  // En UVENTET afbrydelse (chatten er vaek) skal stadig lukke fanerne — derfor
262
270
  // skelnes der, i stedet for bare at tjekke om sessionen er tom.
271
+ // #12: sessionen forsvinder her — dens frist skal med, uanset hvilken vej vi gaar ud.
272
+ chrome.alarms.clear(`frigiv-${port}`).catch(() => {});
273
+
263
274
  if (frivilligtFrigivet.delete(port)) {
264
275
  if (session.tabIds.size) {
265
276
  // Sessionen arbejder igen. Behold den, saa naeste binding kan adoptere den
@@ -273,12 +284,35 @@ async function releaseSession(port) {
273
284
  }
274
285
 
275
286
  // Detach debugger + close all session tabs
276
- const tabIds = [...session.tabIds];
277
- for (const tabId of tabIds) {
287
+ //
288
+ // #13: fejlene blev slugt her, og `sessions.delete(port)` koerte alligevel. En fane der
289
+ // ikke KUNNE lukkes blev dermed foraeldreloes: stadig aaben, stadig i en farvet gruppe,
290
+ // men uden for enhver session — usynlig for list_tabs og aldrig ryddet op. Praeeksisterende,
291
+ // men v1.29 slipper porte langt oftere, saa stien koeres langt hyppigere end foer.
292
+ const stadigAabne = new Set();
293
+ for (const tabId of [...session.tabIds]) {
278
294
  debuggerForceDetach(tabId);
279
295
  try {
280
296
  await chrome.tabs.remove(tabId);
281
- } catch {} // tab may already be closed
297
+ session.tabIds.delete(tabId);
298
+ } catch {
299
+ // Fanen kan vaere lukket i forvejen — saa er den vaek, og det er fint. Findes den
300
+ // stadig, beholder vi den: bedre en session der lever lidt for laenge end en fane
301
+ // ingen ejer.
302
+ try {
303
+ await chrome.tabs.get(tabId);
304
+ stadigAabne.add(tabId);
305
+ } catch {
306
+ session.tabIds.delete(tabId);
307
+ }
308
+ }
309
+ }
310
+
311
+ if (stadigAabne.size) {
312
+ console.warn('[BG] kunne ikke lukke', [...stadigAabne], '— sessionen beholdes saa fanerne ikke strander');
313
+ session.tabIds = stadigAabne;
314
+ persistSessions();
315
+ return;
282
316
  }
283
317
 
284
318
  sessions.delete(port);
@@ -476,13 +510,26 @@ chrome.debugger.onDetach.addListener((source, reason) => {
476
510
  // Side-effectful methods (Input.*, DOM.setFileInputFiles) must NEVER auto-retry:
477
511
  // Chrome may detach AFTER processing the input (e.g., keystroke triggered navigation),
478
512
  // and a blind retry would double-type or double-click.
513
+ // MAALT 9/9-2026 (fundet af Astra, reproduceret her): `Runtime.evaluate` stod paa listen,
514
+ // fordi de fleste kald er laesninger. Men vi kan ikke afgoere paa metodenavnet om et
515
+ // udtryk MUTERER — og flere af dem goer:
516
+ // * settle-udtrykket i debuggerClick FYRER reserveloesnings-klikket
517
+ // * scroll'ens reserveloesning kalder window.scrollBy
518
+ // * fill skriver i feltet gennem evalAttached
519
+ // Reproduktion: cdpSend(1,'Runtime.evaluate',{expression:'window.tael++'}) med en
520
+ // detach-fejl koerte udtrykket FIRE gange. Paa en SPA hvor debuggeren falder af, kunne
521
+ // det altsaa lande fire klik — paa en knap der maaske bestiller noget.
522
+ //
523
+ // Vi kan ikke skelne, saa standarden skal vaere sikker. Falder debuggeren af midt i en
524
+ // evaluering, faar kalderen fejlen og kan selv beslutte om det er forsvarligt at gentage.
525
+ // Prisen er en tabt gentagelse paa anti-automatiserings-sider; alternativet er et
526
+ // dobbeltklik, og de to ting er ikke lige slemme.
479
527
  const RETRYABLE_CDP_METHODS = new Set([
480
528
  'DOM.getDocument',
481
529
  'DOM.querySelector',
482
530
  'DOM.querySelectorAll',
483
531
  'DOM.focus',
484
532
  'DOM.describeNode',
485
- 'Runtime.evaluate',
486
533
  'Runtime.enable',
487
534
  'Page.captureScreenshot',
488
535
  'Page.enable',
@@ -494,6 +541,86 @@ const RETRYABLE_CDP_METHODS = new Set([
494
541
  // CDP wrapper with auto-recovery: re-attaches on detach errors.
495
542
  // For read-only methods (whitelist above), retries once after re-attach.
496
543
  // For side-effectful methods, only re-attaches and throws — caller must decide.
544
+ // MAALT 8/9-2026: `Input.dispatchMouseEvent` med mouseWheel indfrier ALDRIG sit loefte.
545
+ // `browser_scroll` med pixels ramte derfor serverens 30-sekunders-loft 6 kald ud af 6,
546
+ // paa baade en kort og en lang side — og den `window.scrollBy` der er skrevet til netop
547
+ // det tilfaelde, ligger i et `catch` og kunne aldrig naas. En haenger er ikke en exception.
548
+ // Fristen findes for at reserveloesningen kan naas. Alt der HAR en reserveloesning skal
549
+ // kunne naas via en frist, ikke kun via en fejl.
550
+ // Foerste udgave satte 1.500 ms paa ALT. Astra fandt 9/9 at det indfoerte en ny fejlklasse:
551
+ // dialog-ventetider bruger 3.000 ms, og et Page.captureScreenshot paa en tung side kan
552
+ // lovligt tage laengere. Fristen skar dem over, og skaermbilledets reserveloesning blev
553
+ // dermed naaet i almindelig drift — se rettelse B nedenfor.
554
+ //
555
+ // Den hang der blev MAALT var Input.dispatchMouseEvent med mouseWheel, som aldrig indfrier
556
+ // sit loefte. Laesekald har i forvejen deres egne ydre frister (evaluerTaalmodigt).
557
+ // Derfor: kort frist paa input-kald, og en rundhaandet bagstopper paa resten — stadig under
558
+ // serverens 30 s, saa kalderens reserveloesning kan naas.
559
+ const CDP_FRIST_INPUT_MS = 1500;
560
+ const CDP_FRIST_MS = 8000;
561
+ // MAALT 9/9 af reviewet: ét loft paa alt braekker tre ting. Et skaermbillede paa en tung
562
+ // side bruger lovligt mere end 8 s; `execute_script` med awaitPromise venter paa BRUGERENS
563
+ // egen kode, som serveren giver 30 s; og et enkelt tastetryk paa en side med validering
564
+ // pr. anslag kan lovligt overskride input-fristen. Fristen skal derfor kende kaldet.
565
+ const CDP_FRIST_TUNG_MS = 20000; // under serverens 30 s, men over alt lovligt
566
+
567
+ function cdpFrist(method, params) {
568
+ const m = String(method);
569
+ // Brugerens egen kode maa vente: awaitPromise betyder "vent paa dette loefte".
570
+ if (m === 'Runtime.evaluate' && params && params.awaitPromise) return CDP_FRIST_TUNG_MS;
571
+ if (m === 'Page.captureScreenshot' || m === 'Network.getResponseBody') return CDP_FRIST_TUNG_MS;
572
+ return m.startsWith('Input.') ? CDP_FRIST_INPUT_MS : CDP_FRIST_MS;
573
+ }
574
+
575
+ // Skaermbilledet har et budget for HELE kaeden, ikke kun pr. kald.
576
+ // MAALT 11/9 af Astra (sign-off, R5 F8): standardoptagelsen hang og koblede foerst fra efter 19 s. cdpSend gentog den,
577
+ // fordi den staar som sikker at gentage, og billedet kom efter ca. 38 s - serveren havde opgivet ved 30 s. I 1.29.0
578
+ // sendte en frist paa 8 s kaldet videre til fromSurface:false, som svarede paa et halvt sekund.
579
+ // Standardoptagelsen faar derfor 10 s. Reserven faar resten af budgettet, saa svaret naar frem foer serverens frist.
580
+ // MAALT 11/9 af Fable (e2e-review): haenger BEGGE optagelser, haevede 1.29.0 vinduet og leverede et billede efter 16,7 s.
581
+ // En frist afskar den sidste udvej her, saa der kom intet billede. Budgettet reserverer derfor haevMs til den haevede runde.
582
+ //
583
+ // MAALT 12/9 af Astra (tredje runde paa samme sted): en gentilslutning paa 12,5 s betoed at kandidaten opgav efter 26,9 s,
584
+ // hvor 1.29.0 leverede et billede efter 29,2 s. Aarsagen var ikke logikken, men TALLET: 26 s var valgt frit, ikke udledt.
585
+ // Hver runde fandt et nyt scenarie i de 4 sekunder vi gav bort. Budgettet er derfor nu udledt af det eneste tal der
586
+ // betyder noget - serverens egen frist pr. kald (sendToExtension i mcp-server/index.js) - minus den tid svaret skal
587
+ // bruge paa at komme tilbage gennem broen. Aendrer serverens frist sig, foelger budgettet med.
588
+ const SERVER_FRIST_MS = 30000; // sendToExtension(..., timeoutMs = 30000) i mcp-server/index.js
589
+ // MAALT 12/9 af Astra: hjemrejsen for en skaermbillede-stor nyttelast over den lokale WebSocket er 84-154 ms
590
+ // (0,5 / 2 / 8 MB, tre maalinger hver). 1500 ms var 10x det - altsaa endnu et frit valgt tal i den regel der lige var
591
+ // lukket. 500 ms er tre gange det maalte og krymper baandet, hvor 1.29.0 leverer og vi ikke goer, fra ~1,35 s til ~0,35 s.
592
+ const SVARETS_HJEMREJSE_MS = 500; // udvidelse -> offscreen -> WebSocket -> server
593
+ function skaermbilledeFrister() {
594
+ return { foersteMs: 10000, samletMs: SERVER_FRIST_MS - SVARETS_HJEMREJSE_MS, haevMs: 8000 };
595
+ }
596
+
597
+ // wait_for_network har samme loft hos serveren (30 s). MAALT 11/9 af Astra (e2e-review): svaret kom efter 13 s, body-kaldet
598
+ // fik CDP_FRIST_TUNG_MS (20 s) oveni, og serveren opgav ved 30 s, hvor 1.29.0 svarede efter 21 s med body:null.
599
+ // Hele vaerktoejet har derfor ét budget, og body-kaldet faar kun det der er tilbage.
600
+ // Astra (efterproevning af c826f63): budgettet skar en body over der kom efter 27 s + 2 s, som 1.29.0 leverede efter 29 s.
601
+ // Body-kaldet faar derfor aldrig kortere tid end 1.29.0's frist (CDP_FRIST_MS) og aldrig mere end CDP_FRIST_TUNG_MS.
602
+ // MAALT 12/9 af Astra: her stod 28000 haardkodet - det SAMME frie tal som skaermbilledets budget lige var sluppet af med.
603
+ // Begge budgetter udledes nu af serverens egen frist, saa reglen gaelder hele vejen og ikke kun dér hvor den blev fundet.
604
+ function netvaerkFrister() {
605
+ return { budgetMs: SERVER_FRIST_MS - SVARETS_HJEMREJSE_MS, bodyMinMs: CDP_FRIST_MS, bodyMaxMs: CDP_FRIST_TUNG_MS };
606
+ }
607
+
608
+ function cdpMedFrist(tabId, method, params) {
609
+ let ur;
610
+ const frist = cdpFrist(method, params);
611
+ return Promise.race([
612
+ chrome.debugger.sendCommand({ tabId }, method, params),
613
+ new Promise((_, afvis) => {
614
+ // MAALT 11/9 (raa CDP-proeve i Chrome for Testing): Chrome leverer ikke Input.* til en fane i baggrunden.
615
+ // Kaldet haenger, og efter aktivering svarer det paa millisekunder. Fejlen siger det, saa agenten ved hvad den goer.
616
+ const hint = String(method).startsWith('Input.')
617
+ ? ' (fanen er sandsynligvis i baggrunden - Chrome leverer ikke mus og taster til en fane der ikke er aktiv; kald browser_switch_tab og proev igen)'
618
+ : '';
619
+ ur = setTimeout(() => afvis(new Error(`CDP svarede ikke inden ${frist} ms: ${method}${hint}`)), frist);
620
+ }),
621
+ ]).finally(() => clearTimeout(ur));
622
+ }
623
+
497
624
  async function cdpSend(tabId, method, params = {}) {
498
625
  await debuggerAttach(tabId);
499
626
  let lastMsg = '';
@@ -502,7 +629,7 @@ async function cdpSend(tabId, method, params = {}) {
502
629
  // where Chrome re-detaches between attach and command execution.
503
630
  for (let attempt = 0; attempt < 4; attempt++) {
504
631
  try {
505
- return await chrome.debugger.sendCommand({ tabId }, method, params);
632
+ return await cdpMedFrist(tabId, method, params);
506
633
  } catch (e) {
507
634
  const msg = e?.message || String(e);
508
635
  const isDetachError =
@@ -539,6 +666,23 @@ const agentLukkedeFaner = new Set();
539
666
  // Skelnen betyder alt i releaseSession: en frivillig frigivelse maa aldrig lukke
540
667
  // faner, mens en uventet afbrydelse (chatten er vaek) netop skal rydde op.
541
668
  const frivilligtFrigivet = new Set();
669
+ // Hvilken plads en chat sidst havde, husket paa dens pid — ikke paa porten.
670
+ //
671
+ // MAALT 8/9 (#17): siden porten slippes naar en session er faerdig, slettes sessionen.
672
+ // Kommer chatten tilbage, faar den det laveste LEDIGE nummer — saa en chat der var
673
+ // "Claude 3" vender tilbage som "Claude 1" i en anden farve, og brugeren kan ikke
674
+ // genkende sin egen fanegruppe. Identiteten hoerer til chatten, ikke til porten.
675
+ //
676
+ // Garantien er praecis "dit gamle nummer HVIS det er ledigt". Er det taget, vinder den
677
+ // nulevende session — ellers ville vi genindfoere den navnekollision som lavest-ledige-
678
+ // nummer blev indfoert for at loese.
679
+ const pladsPrPid = new Map();
680
+ function husketPlads(pid, nummer) {
681
+ if (typeof pid !== 'number') return;
682
+ pladsPrPid.set(pid, nummer);
683
+ // Kortet maa ikke vokse i det uendelige paa en langtlevende worker.
684
+ if (pladsPrPid.size > 60) pladsPrPid.delete(pladsPrPid.keys().next().value);
685
+ }
542
686
 
543
687
  // ── Armerede dialog-haandterere, pr. fane ──────────────────────────────────────
544
688
  // MAALT 22/8 af flowtesten: handle_dialog var ubrugelig som den var skrevet. Den
@@ -571,10 +715,41 @@ function afvaebnDialog(tabId, grund) {
571
715
  if (grund && a.opfyld) a.opfyld({ ok: false, error: grund });
572
716
  }
573
717
 
718
+ // Fylder sessions-kortet fra lageret UDEN at filtrere paa om fanerne stadig findes.
719
+ //
720
+ // `restoreSessions()` dropper sessioner uden GYLDIGE faner (`validTabIds.size > 0`), og i
721
+ // `tabs.onRemoved` er den fane vi lige har mistet netop den der ville goere sessionen
722
+ // ugyldig. Bruger man restoreSessions dér, forsvinder praecis den session man skal handle
723
+ // paa. Samme faelde som i alarm-lytteren, og derfor samme svar: laes lageret raat.
724
+ async function hydrerSessionerRaat() {
725
+ if (sessions.size) return;
726
+ const { sessions: gemte } = await chrome.storage.local.get({ sessions: {} });
727
+ for (const [port, d] of Object.entries(gemte)) {
728
+ const p = Number(port);
729
+ if (sessions.has(p)) continue;
730
+ sessions.set(p, {
731
+ tabIds: new Set(d.tabIds || []),
732
+ activeTabId: d.activeTabId ?? null,
733
+ groupId: d.groupId ?? null,
734
+ color: d.color,
735
+ label: d.label,
736
+ nummer: d.nummer ?? null,
737
+ pid: d.pid ?? null,
738
+ });
739
+ }
740
+ }
741
+
574
742
  chrome.tabs.onRemoved.addListener((tabId) => {
575
743
  afvaebnDialog(tabId, 'fanen blev lukket foer der kom en dialog');
576
744
  const lukketAfAgenten = agentLukkedeFaner.delete(tabId);
577
745
  debuggerAttached.delete(tabId);
746
+ // MAALT 8/9 (#15): loekken herunder loeb SYNKRONT paa `sessions`. Vaekker eventet en
747
+ // suspenderet service-worker, er kortet tomt, loekken koerer nul gange, og hverken
748
+ // nedlukningen eller fristen bliver sat — porten holdes saa til 4-timers-tomgangen.
749
+ // Agentens egen close_tab ramte det ikke (den kommer som mcp_command, der vaekker og
750
+ // gendanner foerst). Det var specifikt MENNESKET der lukkede den sidste fane, senere.
751
+ (async () => {
752
+ await hydrerSessionerRaat();
578
753
  for (const [port, session] of sessions) {
579
754
  if (!session.tabIds.has(tabId)) continue;
580
755
  session.tabIds.delete(tabId);
@@ -604,6 +779,7 @@ chrome.tabs.onRemoved.addListener((tabId) => {
604
779
  persistSessions();
605
780
  }
606
781
  }
782
+ })().catch(() => {});
607
783
  });
608
784
 
609
785
  // Physical-key `code` for a character, US layout. We used to build this as
@@ -638,18 +814,9 @@ async function typeCharsAttached(tabId, text) {
638
814
  for (let i = 0; i < text.length; i++) {
639
815
  const char = text[i];
640
816
  const code = cdpCodeForChar(char);
641
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
642
- type: 'keyDown',
643
- text: char,
644
- key: char,
645
- ...(code ? { code } : {}),
646
- unmodifiedText: char,
647
- });
648
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
649
- type: 'keyUp',
650
- key: char,
651
- ...(code ? { code } : {}),
652
- });
817
+ await tastParAttached(tabId,
818
+ { text: char, key: char, ...(code ? { code } : {}), unmodifiedText: char },
819
+ { key: char, ...(code ? { code } : {}) });
653
820
  // Human-like typing: random 30-120ms, occasional longer pause
654
821
  const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
655
822
  ? 150 + Math.random() * 200 // thinking pause every ~10 chars
@@ -706,15 +873,59 @@ async function evaluerTaalmodigt(tabId, params, ms = 3000) {
706
873
  let faerdig = false;
707
874
  const kald = cdpSend(tabId, 'Runtime.evaluate', params)
708
875
  .then((r) => { faerdig = true; return r; })
709
- .catch(() => { faerdig = true; });
876
+ .catch((e) => { faerdig = true; return { __fejl: String(e?.message || e) }; });
710
877
  await Promise.race([kald, new Promise((r) => setTimeout(r, ms))]);
711
878
  if (faerdig) return kald;
712
- return { result: { value: { landed: true, fallbackFired: false, rendererSvarede: false } } };
879
+ return { result: { value: { landed: true, fallbackFired: false, rendererSvarede: false, observeret: false } } };
880
+ }
881
+
882
+ // Én regel for om et klik landede - brugt af click, click_xy og select_option. Astra, tredje runde:
883
+ // select_option havde stadig den gamle ("ikke falsk" = landet), saa et uvist klik blev til ok:true.
884
+ function klikLandede(r) {
885
+ return r?.landed === true || r?.detached === true;
886
+ }
887
+
888
+ // Teksten der foelger med et uvist klik. Staar ét sted, fordi den skal vaere ens for click, click_xy og select_option -
889
+ // tre kaldesteder med den samme regel har foer drevet fra hinanden (select_option havde den gamle i to udgaver).
890
+ const UVIST_NOTE = 'Klikket blev sendt, og siden aendrede sig paa mousedown - men ikke af selve klikket. Det kan vaere en ripple, og det kan vaere en menu der aabner paa mousedown. Tjek tilstanden foer du klikker igen: et klik nummer to lukker en menu der allerede er aaben.';
891
+ const UVERIFICERET_NOTE = 'Museknappen blev sendt, men siden kunne ikke laeses bagefter (opslaget fejlede, og siden navigerede ikke). ' +
892
+ 'Klikket kan vaere landet. Tjek tilstanden foer du klikker igen.';
893
+
894
+ // MAALT 12/9 af Astra: der er TO uvisheds-kanaler - `uvist` (kun mousedown aendrede noget) og `uverificeret`
895
+ // (settle-opslaget fejlede uden navigation, men museknappen ER sendt). Rettelsen roerte kun den foerste, saa den anden
896
+ // gav stadig et bart ok:false hvor 1.29.0 gav ok:true - og et bart nej faar agenten til at klikke igen.
897
+ // Betingelsen spoerger derfor paa landed === null, ikke paa ét flag: en fremtidig tredje kanal er daekket fra dag ét.
898
+ function uvisVurdering(r) {
899
+ if (!r || r.landed !== null) return null;
900
+ if (r.uvist) return { maaske_landet: true, note: UVIST_NOTE };
901
+ if (r.uverificeret) return { maaske_landet: true, note: UVERIFICERET_NOTE };
902
+ return { maaske_landet: true, note: 'Handlingen blev sendt, men virkningen kunne ikke bekraeftes. Tjek tilstanden foer du gentager den.' };
903
+ }
904
+
905
+ // MAALT 10/9 af Astra (anden runde): et settle-opslag der FEJLEDE gav null, og null blev til ok:true.
906
+ // Men den hyppigste grund til at opslaget fejler er at klikket navigerede - det er en virkning, og agenten
907
+ // maa ikke faa at vide at den skal klikke igen.
908
+ // Tredje runde: "detached", "closed" og "Cannot find" er IKKE bevis for navigation - afkobling sker ogsaa
909
+ // naar nogen aabner DevTools. Bevis er nu: fanen er lukket, adressen er skiftet, eller JavaScript-konteksten
910
+ // blev revet ned (det sker kun naar dokumentet blev udskiftet). Alt andet er uvist og siges som uvist.
911
+ async function tolkManglendeSettle(tabId, settle, urlFoer) {
912
+ const fejl = settle?.__fejl || 'settle-opslaget gav intet svar';
913
+ const fane = await chrome.tabs.get(tabId).catch(() => null);
914
+ const urlEfter = fane?.url ?? null;
915
+ const kontekstVaek = /Execution context was destroyed|Cannot find context with specified id|Inspected target navigated/i.test(fejl);
916
+ if (!fane || (urlFoer && urlEfter && urlEfter !== urlFoer) || kontekstVaek) {
917
+ return { landed: false, fallbackFired: false, detached: true, navigerede: true };
918
+ }
919
+ return { landed: null, fallbackFired: false, uverificeret: true, fejl };
713
920
  }
714
921
 
715
922
  async function debuggerClick(tabId, x, y) {
716
923
  await debuggerAttach(tabId);
924
+ // Er museknappen sendt ned, kan klikket vaere landet - saa maa en fejl bagefter ikke fore til et klik til.
925
+ let trykSendt = false;
717
926
  try {
927
+ // Adressen foer klikket - et skift bagefter er bevis for en virkning (se tolkManglendeSettle).
928
+ const urlFoer = (await chrome.tabs.get(tabId).catch(() => null))?.url ?? null;
718
929
  // 0. Capture the DEEPEST target element under the point BEFORE dispatching.
719
930
  // Web-components (Google Ads <button-panel>, Material Web) keep their real
720
931
  // <button> inside an (open) shadow root, so we pierce shadow roots to reach
@@ -757,52 +968,135 @@ async function debuggerClick(tabId, x, y) {
757
968
  // 0 on release) plus a small press→release gap are REQUIRED for Chrome to
758
969
  // synthesize a *trusted* 'click' from the pair. Without them, web-components
759
970
  // that gate on the trusted click event (Google Ads, Material Web) never fire.
760
- await dispatchTaalmodigt(tabId, {
761
- type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
762
- });
763
- await new Promise(r => setTimeout(r, 30));
764
- await dispatchTaalmodigt(tabId, {
765
- type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
766
- });
971
+ trykSendt = true;
972
+ // Astra, tredje runde: fejlede mousePressed EFTER at vaere leveret, blev mouseReleased aldrig sendt,
973
+ // og siden stod tilbage med knappen nede (drag-tilstand). Knappen slippes nu altid.
974
+ let trykFejl = null;
975
+ try {
976
+ await dispatchTaalmodigt(tabId, {
977
+ type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
978
+ });
979
+ await new Promise(r => setTimeout(r, 30));
980
+ } catch (e) { trykFejl = e; }
981
+ try {
982
+ await dispatchTaalmodigt(tabId, {
983
+ type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
984
+ });
985
+ } catch (e) { trykFejl = trykFejl || e; }
986
+ if (trykFejl) throw trykFejl;
767
987
  // 3. Framework fallback — only if the captured target is STILL connected (i.e.
768
988
  // the trusted click in step 2 did not already handle it). Settle delay lets
769
989
  // SPA re-renders (Google Ads) detach the element first. Fires a full pointer
770
990
  // + mouse sequence on the shadow-pierced target, then React/Angular handlers.
771
991
  await new Promise(r => setTimeout(r, 120));
992
+ // MAALT 9/9-2026: oprydningen (removeEventListener + delete) laa FOER reserveloesningen
993
+ // fyrede. Derfor kunne intet observere om det syntetiske klik virkede, og udtrykket
994
+ // svarede landed:false som et GAET. Paa en div-baseret dropdown betoed det
995
+ // {ok:true, landed:false} og en menu der aldrig aabnede — issue #19.
996
+ // Nu staar lytteren stadig paa document mens reserveloesningen fyrer, og laeses bagefter:
997
+ // det er forskellen paa en maaling og en antagelse. Der ryddes op paa hver udgang.
998
+ // NB: ingen backticks i udtrykket herunder — det ER et template literal.
772
999
  const settle = await evaluerTaalmodigt(tabId, {
773
1000
  returnByValue: true,
774
1001
  expression: `(() => {
775
1002
  const el = window.__bmcpClickTarget;
776
1003
  const landed = window.__bmcpClicked === true;
777
- try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
778
- try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
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
1004
+ const ryd = () => {
1005
+ try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
1006
+ try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
1007
+ };
1008
+ // Et billigt fingeraftryk af det et klik plejer at aendre: antal noder, synlig tekst,
1009
+ // adressen, og om noget er aabnet/valgt. Bevidst groft — det skal kunne tages to gange
1010
+ // paa faa millisekunder, ikke beskrive siden.
1011
+ // MAALT 11/9 af Astra (R5 F6): tekst og feltvaerdier blev talt i LAENGDE, saa AAAA -> BBBB var
1012
+ // usynlig, og et klik der virkede blev meldt som fejl. Nu hashes indholdet (FNV-1a, 32 bit).
1013
+ const hash = (s) => {
1014
+ let x = 2166136261;
1015
+ for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = Math.imul(x, 16777619); }
1016
+ return (x >>> 0).toString(36);
1017
+ };
1018
+ const aftryk = () => {
1019
+ try {
1020
+ return document.querySelectorAll('*').length + '|' +
1021
+ hash(document.body ? String(document.body.innerText) : '') + '|' +
1022
+ location.href + '|' +
1023
+ document.querySelectorAll('[aria-expanded="true"],[aria-selected="true"],[open],.open,.active').length + '|' +
1024
+ // Astra, anden runde: en afkrydsning eller en feltvaerdi aendrer hverken noder, tekst eller
1025
+ // adresse - saa et klik der VIRKEDE blev meldt som fejl, og et nyt klik ville fortryde det.
1026
+ document.querySelectorAll('input:checked,option:checked').length + '|' +
1027
+ hash(Array.from(document.querySelectorAll('input,textarea,select')).map((e) => String(e.value || '')).join(''));
1028
+ } catch (e) { return 'aftryk-fejlede'; }
1029
+ };
1030
+ if (landed) { ryd(); return { landed: true, fallbackFired: false }; } // FIX-13: trusted click already landed — do NOT double-fire
1031
+ if (el === null) { ryd(); return { landed: false, fallbackFired: false, intetMaal: true }; } // intet element under punktet (fx uden for vinduet) - ingen virkning
1032
+ if (!el || !el.isConnected) { ryd(); return { landed: false, fallbackFired: false, detached: true }; } // already navigated/handled — don't double-fire
1033
+ const foerAftryk = aftryk();
781
1034
  const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
782
- try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (e) {}
1035
+ // Samme moenster som script-klikket (Astra 11/9): en PointerEvent uden pointerType er ikke en mus.
1036
+ const mus = { ...opts, pointerType: 'mouse', isPrimary: true, pointerId: 1, button: 0 };
1037
+ try { el.dispatchEvent(new PointerEvent('pointerdown', { ...mus, buttons: 1 })); } catch (e) {}
783
1038
  el.dispatchEvent(new MouseEvent('mousedown', opts));
784
- try { el.dispatchEvent(new PointerEvent('pointerup', opts)); } catch (e) {}
1039
+ try { el.dispatchEvent(new PointerEvent('pointerup', { ...mus, buttons: 0 })); } catch (e) {}
785
1040
  el.dispatchEvent(new MouseEvent('mouseup', opts));
786
- el.dispatchEvent(new MouseEvent('click', opts));
1041
+ // MAALT 10/9 i en rigtig browser: her stod BEGGE — dispatchEvent('click') OG el.click().
1042
+ // Det er to klik-haendelser. Paa alt hvad der skifter tilstand (dropdowns, faneblade,
1043
+ // afkrydsningsfelter, harmonikaer) aabner det foerste og det andet lukker igen, saa
1044
+ // resultatet er intet. Det er aarsagen til at issue #19's div-dropdown "aldrig aabnede":
1045
+ // den aabnede og lukkede inde i den samme reserveloesning.
1046
+ // ét klik: aftryk 11|53|…|0 -> 11|69|…|1 (menuen aaben)
1047
+ // to klik: aftryk 11|53|…|0 -> 11|53|…|0 (tilbage ved start)
1048
+ // el.click() foretraekkes, fordi den ogsaa udloeser elementets aktiverings-adfaerd
1049
+ // (foelg link, skift afkrydsning) — det goer en syntetisk MouseEvent ikke paalideligt.
1050
+ // MAALT 11/9 af Astra (R5 F5): aftrykket til afgoerelsen herunder var foerAftryk fra FOER mousedown.
1051
+ // En ripple-node fra mousedown lignede derfor en virkning af el.click(), React blev sprunget over,
1052
+ // og svaret blev landed:true med nul handling. Afgoerelsen maaler nu kun hvad el.click() gjorde.
1053
+ const foerKlik = aftryk();
787
1054
  if (typeof el.click === 'function') el.click();
1055
+ else el.dispatchEvent(new MouseEvent('click', opts));
1056
+
1057
+ // MAALT 10/9 af Astra (anden runde): React-fiberens onClick blev kaldt UBETINGET efter el.click() -
1058
+ // men el.click() udloeser allerede Reacts handler. To koersler, og en toggle endte hvor den startede.
1059
+ // Framework-vejene er til elementer hvor det native klik INGEN virkning gav.
1060
+ if (aftryk() === foerKlik) {
1061
+ // React fiber fallback — find and call onClick handler directly
1062
+ const fiberKey = Object.keys(el).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
1063
+ if (fiberKey) {
1064
+ let fiber = el[fiberKey];
1065
+ for (let i = 0; i < 10 && fiber; i++) {
1066
+ if (fiber.memoizedProps?.onClick) { fiber.memoizedProps.onClick(new MouseEvent('click', {bubbles:true})); break; }
1067
+ fiber = fiber.return;
1068
+ }
1069
+ }
788
1070
 
789
- // React fiber fallback — find and call onClick handler directly
790
- const fiberKey = Object.keys(el).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
791
- if (fiberKey) {
792
- let fiber = el[fiberKey];
793
- for (let i = 0; i < 10 && fiber; i++) {
794
- if (fiber.memoizedProps?.onClick) { fiber.memoizedProps.onClick(new MouseEvent('click', {bubbles:true})); break; }
795
- fiber = fiber.return;
1071
+ // Angular Material fallback — ripple + internal handlers
1072
+ const ngKey = Object.keys(el).find(k => k.startsWith('__ng'));
1073
+ if (ngKey || el.getAttribute('ng-click') || el.getAttribute('(click)')) {
1074
+ const matRipple = el.closest && el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
1075
+ if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
796
1076
  }
797
1077
  }
798
1078
 
799
- // Angular Material fallback ripple + internal handlers
800
- const ngKey = Object.keys(el).find(k => k.startsWith('__ng'));
801
- if (ngKey || el.getAttribute('ng-click') || el.getAttribute('(click)')) {
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');
803
- if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
804
- }
805
- return { landed: false, fallbackFired: true };
1079
+ // Lytteren kan IKKE bruges her. Den udloeses af enhver dispatch paa maalet, og
1080
+ // reserveloesningen dispatcher netop paa maalet — saa flaget ville vaere sandt fordi
1081
+ // VI sendte noget, ikke fordi siden reagerede. Reproduceret 9/9 mod et <div> uden
1082
+ // nogen handler: foer=false, efter=true.
1083
+ // Derfor maales sidens REAKTION i stedet, med samme aftryks-greb som select_option
1084
+ // allerede bruger: aendrede noget sig af det et klik plejer at aendre?
1085
+ // MAALT 12/9 af Fable (e2e runde 3): her sammenlignedes med aftrykket fra FOER mousedown.
1086
+ // Rettelsen af R5 F5 ovenfor gjaldt kun HVILKEN vej der blev proevet, ikke hvad vi SVAREDE. Uden en framework-vej
1087
+ // at falde tilbage paa gav en ripple derfor stadig landed:true med nul handling.
1088
+ // Tre udfald, ikke to: aendrede klikket (eller framework-vejen) noget, er det landet. Aendrede kun mousedown
1089
+ // noget, ved vi det ikke - det kan vaere en ripple, men ogsaa en menu der aabner paa mousedown. Saa siges der
1090
+ // hverken ja eller nej: landed null bliver til maaske_landet hos kalderen, med den maalte tilstand.
1091
+ const efterAftryk = aftryk();
1092
+ const klikketVirkede = efterAftryk !== foerKlik;
1093
+ const kunMousedown = !klikketVirkede && foerKlik !== foerAftryk;
1094
+ ryd();
1095
+ return {
1096
+ landed: kunMousedown ? null : klikketVirkede,
1097
+ ...(kunMousedown ? { uvist: true } : {}),
1098
+ fallbackFired: true, aftrykFoer: foerAftryk, aftrykEfter: efterAftryk,
1099
+ };
806
1100
  })()`,
807
1101
  });
808
1102
  const vaerdi = settle?.result?.value ?? null;
@@ -819,7 +1113,10 @@ async function debuggerClick(tabId, x, y) {
819
1113
  dialogLoefter.delete(tabId);
820
1114
  }
821
1115
  }
822
- return vaerdi;
1116
+ return vaerdi ?? await tolkManglendeSettle(tabId, settle, urlFoer);
1117
+ } catch (e) {
1118
+ if (trykSendt && e && typeof e === 'object') e.trykSendt = true;
1119
+ throw e;
823
1120
  } finally {
824
1121
  await debuggerDetach(tabId);
825
1122
  }
@@ -851,20 +1148,21 @@ async function evalAttached(tabId, expression) {
851
1148
  return result.result?.value;
852
1149
  }
853
1150
 
1151
+ // En tast ned og op igen - og op igen UANSET hvad. MAALT 10/9 af Astra (anden runde): press_key fik
1152
+ // keyUp-altid i foerste runde, men de tre hjaelpere der ogsaa sender taster gjorde ikke. Timede et
1153
+ // keyDown ud efter at det VAR landet, forlod hjaelperen funktionen foer sit keyUp, og tasten sad fast
1154
+ // for siden. Et fastsiddende Cmd goer det naeste tastetryk til en genvej.
1155
+ async function tastParAttached(tabId, ned, op) {
1156
+ let fejl = null;
1157
+ try { await cdpSend(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ned }); } catch (e) { fejl = e; }
1158
+ try { await cdpSend(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...op }); } catch (e) { fejl = fejl || e; }
1159
+ if (fejl) throw fejl;
1160
+ }
1161
+
854
1162
  // Select-all + Backspace. Assumes the debugger is already attached.
855
1163
  async function clearFieldAttached(tabId) {
856
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
857
- type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
858
- });
859
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
860
- type: 'keyUp', key: 'a', code: 'KeyA',
861
- });
862
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
863
- type: 'keyDown', key: 'Backspace', code: 'Backspace',
864
- });
865
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
866
- type: 'keyUp', key: 'Backspace', code: 'Backspace',
867
- });
1164
+ await tastParAttached(tabId, { key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS }, { key: 'a', code: 'KeyA' });
1165
+ await tastParAttached(tabId, { key: 'Backspace', code: 'Backspace' }, { key: 'Backspace', code: 'Backspace' });
868
1166
  }
869
1167
 
870
1168
  async function debuggerFill(tabId, selector, value) {
@@ -900,6 +1198,37 @@ async function debuggerFill(tabId, selector, value) {
900
1198
  try {
901
1199
  await clearFieldAttached(tabId);
902
1200
 
1201
+ // ── Blev feltet FAKTISK tomt? (MAALT 8/9) ────────────────────────────────
1202
+ //
1203
+ // `clearFieldAttached` sender Cmd/Ctrl+A og Backspace som aegte tastetryk. Paa et
1204
+ // React-styret felt tommer det ikke: maalt paa forbrugeragenten.dk/penge-tilbage gav
1205
+ // to fill-kald efter hinanden vaerdien "test@example.dkanden@example.dk" — begge kald
1206
+ // svarede ok:true. Vaerktoejet meldte succes og gjorde noget andet end det lovede.
1207
+ //
1208
+ // Et fill paa et TOMT felt var rent i samme maaling, saa fejlen sidder alene her.
1209
+ // Derfor: laes tilbage, og ryd med den vej der virker paa styrede felter hvis
1210
+ // tastetrykkene ikke slog igennem. Vi gaetter ikke paa hvorfor — vi tjekker.
1211
+ const restVaerdi = await evalAttached(tabId, `
1212
+ (function() {
1213
+ const el = document.activeElement;
1214
+ return el && 'value' in el ? el.value : '';
1215
+ })()
1216
+ `);
1217
+ if (restVaerdi) {
1218
+ await evalAttached(tabId, `
1219
+ (function() {
1220
+ const el = document.activeElement;
1221
+ if (!el || !('value' in el)) return false;
1222
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
1223
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
1224
+ if (setter) setter.call(el, ''); else el.value = '';
1225
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1226
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1227
+ return true;
1228
+ })()
1229
+ `);
1230
+ }
1231
+
903
1232
  // Fast path: one trusted InputEvent instead of N key events. This is the same
904
1233
  // primitive set_combobox and set_date already rely on, it avoids per-key `code`
905
1234
  // mapping entirely, and it turns a 40-character value from ~3 seconds of
@@ -971,11 +1300,41 @@ async function scriptingClick(tabId, selector) {
971
1300
  }
972
1301
  if (!el) return { ok: false, reason: 'not_found' };
973
1302
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
974
- const opts = { bubbles: true, cancelable: true, view: window };
1303
+ // Sign-off 11/9 (Astra og Fable, begge reproduceret): paa en baggrundsfane blev et klik der krævede isTrusted,
1304
+ // eller kun lyttede paa pointerdown, meldt ok:true uden nogen virkning. Klikket maaler nu sidens reaktion med
1305
+ // samme aftryk og samme regel som debuggerens reserve (debuggerClick) - ingen backticks herinde.
1306
+ const hash = (s) => {
1307
+ let x = 2166136261;
1308
+ for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = Math.imul(x, 16777619); }
1309
+ return (x >>> 0).toString(36);
1310
+ };
1311
+ const aftryk = () => {
1312
+ try {
1313
+ return document.querySelectorAll('*').length + '|' +
1314
+ hash(document.body ? String(document.body.innerText) : '') + '|' +
1315
+ location.href + '|' +
1316
+ document.querySelectorAll('[aria-expanded="true"],[aria-selected="true"],[open],.open,.active').length + '|' +
1317
+ document.querySelectorAll('input:checked,option:checked').length + '|' +
1318
+ hash(Array.from(document.querySelectorAll('input,textarea,select')).map((e) => String(e.value || '')).join(' '));
1319
+ } catch (e) { return 'aftryk-fejlede'; }
1320
+ };
1321
+ const opts = { bubbles: true, cancelable: true, composed: true, view: window };
1322
+ // Et rigtigt klik sender pointerdown foer mousedown; Radix/shadcn aabner paa pointerdown.
1323
+ // MAALT 11/9 af Astra (efterproevning af 9814636): uden pointerType ("") handlede en side der reagerer paa ikke-mus-
1324
+ // pointere OG paa click to gange. En rigtig mus sender pointerType "mouse".
1325
+ const mus = { ...opts, pointerType: 'mouse', isPrimary: true, pointerId: 1, button: 0 };
1326
+ try { el.dispatchEvent(new PointerEvent('pointerdown', { ...mus, buttons: 1 })); } catch (e) {}
975
1327
  el.dispatchEvent(new MouseEvent('mousedown', opts));
1328
+ try { el.dispatchEvent(new PointerEvent('pointerup', { ...mus, buttons: 0 })); } catch (e) {}
976
1329
  el.dispatchEvent(new MouseEvent('mouseup', opts));
1330
+ // Aftrykket tages lige foer el.click(). MAALT 11/9 af Astra (e2e-review): taget foer mousedown blev en ripple klikbevis
1331
+ // (ok:true, nul handlinger; 1.29.0: fejl). En side der reagerer paa pointerdown, bliver derfor aerligt uvist.
1332
+ const foer = aftryk();
977
1333
  el.click();
978
- return { ok: true, tag: el.tagName };
1334
+ // Maales i samme oejeblik som klikket. Astra (efterproevning af c1496d4): en anden maaling 200 ms senere gjorde et
1335
+ // uafhaengigt ur til klikbevis og et klik der navigerede i ventetiden til en fejl. En sen React-opdatering giver
1336
+ // derfor landed:false og maaske_landet - aerligt, aldrig en falsk succes.
1337
+ return { ok: true, tag: el.tagName, landed: aftryk() !== foer, detached: el.isConnected === false };
979
1338
  },
980
1339
  args: [selector],
981
1340
  });
@@ -1209,6 +1568,20 @@ async function offscreenSvarer() {
1209
1568
  const MAX_OFFSCREEN_GENSKAB = 3;
1210
1569
  const OFFSCREEN_PAUSE_MS = 10 * 60 * 1000;
1211
1570
 
1571
+ // Et omraade paa mere end dette ville faa HVER brugers browser til at probe tusindvis af porte hvert andet sekund.
1572
+ const PORTE_MAX_SPAEND = 200;
1573
+ /** Laeser et alternativt portomraade fra chrome.storage.local. Ugyldigt = standarden (9876-9895) staar. */
1574
+ async function portOmraadeFraLager() {
1575
+ try {
1576
+ const { bmcpPorte } = await chrome.storage.local.get({ bmcpPorte: null });
1577
+ const m = /^(\d{4,5})-(\d{4,5})$/.exec(String(bmcpPorte ?? ''));
1578
+ if (!m) return null;
1579
+ const fra = Number(m[1]), til = Number(m[2]);
1580
+ if (fra < 1024 || til > 65535 || til < fra || til - fra >= PORTE_MAX_SPAEND) return null;
1581
+ return fra + '-' + til;
1582
+ } catch { return null; }
1583
+ }
1584
+
1212
1585
  async function ensureOffscreen() {
1213
1586
  const findes = await chrome.offscreen.hasDocument();
1214
1587
 
@@ -1266,8 +1639,15 @@ async function ensureOffscreen() {
1266
1639
  // baerer den aeldre version, hvilket er praecis den skelnen tjekket skal bruge.
1267
1640
  let minVersion = '';
1268
1641
  try { minVersion = chrome.runtime.getManifest().version; } catch {}
1642
+ // Portomraadet foelger samme vej som versionen. MAALT 12/9 af Astra: serveren sender kun til den NYESTE forbundne
1643
+ // udvidelse, saa en testbrowser paa de samme porte bliver "nyeste" for hver eneste koerende chats server. Et
1644
+ // omraade i chrome.storage.local lader én profil koere isoleret - og fordi det er en VAERDI og ikke en kodeaendring,
1645
+ // er repoets filer byte-identiske, og udgivelsens kode-aftryk er uroert. (Mit foerste forslag, at flytte portene
1646
+ // til manifestet, ville have fjernet dem fra aftrykket - Astra maalte at de ligger i offscreen.js, som hashes.)
1647
+ const porteParam = await portOmraadeFraLager();
1269
1648
  await chrome.offscreen.createDocument({
1270
- url: 'offscreen.html' + (minVersion ? '?v=' + encodeURIComponent(minVersion) : ''),
1649
+ url: 'offscreen.html' + (minVersion ? '?v=' + encodeURIComponent(minVersion) : '')
1650
+ + (porteParam ? (minVersion ? '&' : '?') + 'porte=' + porteParam : ''),
1271
1651
  reasons: ['WORKERS'],
1272
1652
  justification: 'Maintain persistent WebSocket connection to local MCP server',
1273
1653
  });
@@ -1277,22 +1657,42 @@ async function ensureOffscreen() {
1277
1657
 
1278
1658
  const SENSITIVE = new Set(['get_cookies', 'get_local_storage', 'execute_script', 'extract_token']);
1279
1659
 
1280
- function logAction(port, method, params) {
1660
+ // MAALT 11/9 (Astra, efterproevet): her stod `params: JSON.stringify(params).slice(0, 200)`.
1661
+ // De foerste 200 tegn af ALLE parametre — ogsaa vaerdien til fill (adgangskoder), cookie-
1662
+ // vaerdier og adresser med login-tokens — laa i klartekst i chrome.storage.local. Popuppen
1663
+ // viser kun tid, vaerktoej og session, saa parametrene tjente intet. De gemmes ikke laengere,
1664
+ // og poster gemt af aeldre udgaver renses ved opdatering (rensHandlingslog i onInstalled).
1665
+ async function logAction(port, method) {
1281
1666
  const category = SENSITIVE.has(method) ? 'sensitive' : 'safe';
1282
1667
  const session = sessions.get(port);
1283
1668
  const entry = {
1284
1669
  time: Date.now(),
1285
1670
  method,
1286
- params: JSON.stringify(params).slice(0, 200),
1287
1671
  category,
1288
1672
  session: session?.label || `Port ${port}`,
1289
1673
  color: session?.color || 'grey',
1290
1674
  };
1291
- chrome.storage.local.get({ actionLog: [] }, ({ actionLog }) => {
1292
- actionLog.unshift(entry);
1293
- if (actionLog.length > 50) actionLog.length = 50;
1294
- chrome.storage.local.set({ actionLog });
1295
- });
1675
+ try {
1676
+ const { actionLog = [] } = await chrome.storage.local.get({ actionLog: [] });
1677
+ // MAALT 11/9 af Astra (e2e-review): et logAction der koerte samtidig med rensHandlingslog i onInstalled, havde laest den
1678
+ // gamle log foer oprydningen skrev - og skrev saa den gamle adgangskode tilbage. Hver skrivning renser derfor selv.
1679
+ const renset = (Array.isArray(actionLog) ? actionLog : []).filter(Boolean).map(({ params, ...resten }) => resten);
1680
+ renset.unshift(entry);
1681
+ if (renset.length > 50) renset.length = 50;
1682
+ await chrome.storage.local.set({ actionLog: renset });
1683
+ } catch (e) {
1684
+ console.warn('[BG] handlingslog kunne ikke skrives:', e?.message || e);
1685
+ }
1686
+ }
1687
+
1688
+ async function rensHandlingslog() {
1689
+ try {
1690
+ const { actionLog } = await chrome.storage.local.get({ actionLog: [] });
1691
+ if (!Array.isArray(actionLog) || !actionLog.some((p) => p && 'params' in p)) return;
1692
+ await chrome.storage.local.set({ actionLog: actionLog.map(({ params, ...resten }) => resten) });
1693
+ } catch (e) {
1694
+ console.warn('[BG] gammel handlingslog kunne ikke renses:', e?.message || e);
1695
+ }
1296
1696
  }
1297
1697
 
1298
1698
  // ── Message Handler — receives commands from offscreen.js ──────────────────
@@ -1300,7 +1700,7 @@ function logAction(port, method, params) {
1300
1700
  chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
1301
1701
  if (msg.type === 'mcp_command') {
1302
1702
  const port = msg.port;
1303
- logAction(port, msg.method, msg.params);
1703
+ logAction(port, msg.method);
1304
1704
  // Restore sessions from storage (service worker may have restarted), then take over any
1305
1705
  // session orphaned by an unclean disconnect so navigate→click keeps hitting the same tab.
1306
1706
  restoreSessions()
@@ -1367,10 +1767,35 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
1367
1767
 
1368
1768
  const OAUTH_DOMAINS = ['accounts.google.com', 'login.microsoftonline.com', 'github.com/login/oauth', 'slack.com/oauth', 'app.hubspot.com/oauth'];
1369
1769
 
1770
+ // MAALT 10/9, reproduceret i selen: her stod kun `lastCreatedTabId = tab.id` — sat paa
1771
+ // HVER onCreated, ogsaa naar brugeren selv trykker Cmd+T eller aabner sin netbank.
1772
+ // `get_new_tab` adopterede den saa ind i agentens session, hvorefter skaermbillede,
1773
+ // sidetekst og localStorage af BRUGERENS fane var lovligt. Reproduktionen: bruger aabner
1774
+ // brugerens-netbank.example -> get_new_tab svarer med den -> fanen staar i tabIds.
1775
+ //
1776
+ // En ny fane hoerer til en session naar den er aabnet FRA en af dens faner (klik paa et
1777
+ // link med target=_blank, en OAuth-popup). Det staar i openerTabId. Er der ingen opener,
1778
+ // var det brugeren, og saa er den ikke vores.
1370
1779
  let lastCreatedTabId = null;
1780
+ // Aabneren huskes PR. FANE. En global "seneste aabner" blev 10/9 (Astra, reproduceret) laant af
1781
+ // den forkerte fane, naar to faner blev aabnet mens get_new_tab ventede paa tabs.get.
1782
+ const openerForFane = new Map();
1783
+ // MAALT 11/9 af Astra (R5 F9), reproduceret: fane 1 aabner en popup og lukkes derefter (typisk
1784
+ // "log ind i nyt vindue"). get_new_tab slog aabneren op i sessionens faner NU, hvor 1 var vaek,
1785
+ // og svarede not-ours paa sessionens egen popup. Ejeren huskes derfor i det oejeblik fanen
1786
+ // oprettes, hvor aabneren stadig staar i sin session.
1787
+ const ejerForFane = new Map();
1371
1788
 
1372
1789
  chrome.tabs.onCreated.addListener(async (tab) => {
1373
1790
  lastCreatedTabId = tab.id;
1791
+ openerForFane.set(tab.id, tab.openerTabId ?? null);
1792
+ if (openerForFane.size > 500) openerForFane.delete(openerForFane.keys().next().value);
1793
+ let ejer = null;
1794
+ if (tab.openerTabId != null) {
1795
+ for (const [p, s] of sessions) { if (s.tabIds.has(tab.openerTabId)) { ejer = p; break; } }
1796
+ }
1797
+ ejerForFane.set(tab.id, ejer);
1798
+ if (ejerForFane.size > 500) ejerForFane.delete(ejerForFane.keys().next().value);
1374
1799
 
1375
1800
  // Auto-claim OAuth popups for the session that opened them
1376
1801
  if (tab.pendingUrl || tab.url) {
@@ -1428,7 +1853,9 @@ function parsePlaceholderFormat(placeholder) {
1428
1853
  const order = parts.map(p => p.includes('Y') ? 'Y' : p.includes('M') ? 'M' : p.includes('D') ? 'D' : null);
1429
1854
  if (order.includes(null) || new Set(order).size !== 3) return null;
1430
1855
  const padded = parts.map(p => p.length >= 2);
1431
- return { sep, order, padded };
1856
+ // Fjerde runde: YY og YYYY skal kunne skelnes, ellers skrives "2026" i et felt der kun tager to cifre.
1857
+ const lengths = parts.map(p => p.length);
1858
+ return { sep, order, padded, lengths };
1432
1859
  }
1433
1860
 
1434
1861
  function isoToFormat(iso, fmt) {
@@ -1436,7 +1863,7 @@ function isoToFormat(iso, fmt) {
1436
1863
  if (!m) throw new Error('Invalid ISO date: ' + iso);
1437
1864
  const [, y, mo, d] = m;
1438
1865
  return fmt.order.map((slot, i) => {
1439
- if (slot === 'Y') return y;
1866
+ if (slot === 'Y') return fmt.lengths && fmt.lengths[i] === 2 ? y.slice(2) : y;
1440
1867
  if (slot === 'M') return fmt.padded[i] ? mo : String(parseInt(mo, 10));
1441
1868
  if (slot === 'D') return fmt.padded[i] ? d : String(parseInt(d, 10));
1442
1869
  }).join(fmt.sep);
@@ -1459,17 +1886,53 @@ function parseMonthYearText(text) {
1459
1886
  return null;
1460
1887
  }
1461
1888
 
1462
- function valueLooksLikeIso(value, iso) {
1463
- if (!value || !iso) return false;
1889
+ function valueLooksLikeIso(raaVaerdi, iso, fmt) {
1890
+ if (!raaVaerdi || !iso) return false;
1464
1891
  const [y, m, d] = iso.split('-');
1465
- const digits = value.replace(/\D/g, '');
1466
- if (digits.includes(y + m + d)) return true;
1467
- if (digits.includes(m + d + y)) return true;
1468
- if (digits.includes(d + m + y)) return true;
1469
- const hasYear = value.includes(y);
1470
- const hasMonth = value.includes(m) || value.includes(String(parseInt(m, 10)));
1471
- const hasDay = value.includes(d) || value.includes(String(parseInt(d, 10)));
1472
- return hasYear && hasMonth && hasDay;
1892
+ const Y = Number(y), M = Number(m), D = Number(d);
1893
+ // Fjerde runde: en aflaesning der BEGYNDER med den oenskede ISO-dato (fx "2026-01-02T12:00:00") er den dato,
1894
+ // uanset hvilket format placeholderen lover - ellers blev en korrekt dato afvist og kalenderen proevet oveni.
1895
+ if (raaVaerdi.trim().startsWith(iso) && !/\d/.test(raaVaerdi.trim().charAt(iso.length))) return true;
1896
+ // MAALT 11/9 af Astra (R5): klokkeslaet og tidszone er ikke en del af datoen. F2: "02/01 20:26" gav timen 20
1897
+ // som aaret 2020 og ok:true. F3: "02/01/2026 12:00 GMT" blev afvist af bogstavkontrollen paa "GMT", og
1898
+ // kalenderen blev proevet oveni. Begge fjernes foer datoen laeses.
1899
+ const value = raaVaerdi
1900
+ .replace(/\b\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?(\s*[ap]\.?m\.?)?(?![\d:])/gi, ' ')
1901
+ .replace(/\b(GMT|UTC|UT|CET|CEST|EET|EEST|WET|WEST|BST|EST|EDT|CST|CDT|MST|MDT|PST|PDT)\b([+-]\d{1,2}(:?\d{2})?)?/g, ' ')
1902
+ .replace(/\s+/g, ' ')
1903
+ .trim();
1904
+ if (!value) return false;
1905
+ // Astra, anden runde: tre `value.includes(...)` hver for sig godkendte "20/12/2026" som 2026-01-02.
1906
+ // Tredje runde: tre `digits.includes(...)` koerte stadig FOER kontrollen af hele tal, saa
1907
+ // "2026-1-1 02:00" blev 2026-11-02. Nu sammenlignes cifre som én streng KUN naar vaerdien udelukkende
1908
+ // ER otte cifre - og med kendt format kun i formatets raekkefoelge ("01/12/2026" under DD/MM/YYYY er
1909
+ // 1. december, ikke 12. januar).
1910
+ const ordener = fmt?.order ? [fmt.order] : [['Y', 'M', 'D'], ['D', 'M', 'Y'], ['M', 'D', 'Y']];
1911
+ const del = { Y: y, M: m, D: d };
1912
+ if (/^\s*\d{8}\s*$/.test(value)) {
1913
+ const cifre = value.trim();
1914
+ return ordener.some((o) => o.map((slot) => del[slot]).join('') === cifre);
1915
+ }
1916
+ // Hele tal fra vaerdiens START - et klokkeslaet bagefter maa ikke levere dag eller maaned.
1917
+ const tok = value.match(/\d+/g) || [];
1918
+ const passer = (slot, s) => {
1919
+ if (s === undefined) return false;
1920
+ if (slot === 'Y') return (s.length === 4 && Number(s) === Y) || (s.length === 2 && Number(s) === Y % 100);
1921
+ return s.length <= 2 && Number(s) === (slot === 'M' ? M : D);
1922
+ };
1923
+ // Fjerde runde: rene tal kun naar vaerdien ingen bogstaver har - i "2 Jan 26 05:00" blev timen ellers et aarstal.
1924
+ if (!/\p{L}/u.test(value) && ordener.some((o) => o.every((slot, i) => passer(slot, tok[i])))) return true;
1925
+ // Maanedsnavn ("2 Jan 2026", "2. maj 2026", "Jan 2, 2026"): dagen skal staa lige foer eller lige efter navnet.
1926
+ const navne = [/jan/, /feb/, /mar/, /apr/, /ma[iyj]/, /jun/, /jul/, /aug/, /sep/, /o[ck]t/, /nov/, /de[cz]/];
1927
+ const navn = navne[M - 1];
1928
+ if (navn) {
1929
+ const lav = value.toLowerCase();
1930
+ const n = navn.source;
1931
+ const foer = new RegExp('^\\s*(\\d{1,2})\\.?\\s+' + n + '[a-zæøå]*\\.?,?\\s+(\\d{4})(?!\\d)').exec(lav);
1932
+ const efter = new RegExp('^\\s*' + n + '[a-zæøå]*\\.?\\s+(\\d{1,2}),?\\s+(\\d{4})(?!\\d)').exec(lav);
1933
+ for (const r of [foer, efter]) if (r && Number(r[1]) === D && Number(r[2]) === Y) return true;
1934
+ }
1935
+ return false;
1473
1936
  }
1474
1937
 
1475
1938
  async function getDateInputInfo(tabId, selector) {
@@ -1538,25 +2001,9 @@ async function setDateMaskedTyping(tabId, selector, iso, format) {
1538
2001
  await debuggerFocus(tabId, selector);
1539
2002
  await debuggerAttach(tabId);
1540
2003
  try {
1541
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1542
- type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
1543
- });
1544
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1545
- type: 'keyUp', key: 'a', code: 'KeyA',
1546
- });
1547
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1548
- type: 'keyDown', key: 'Backspace', code: 'Backspace',
1549
- });
1550
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1551
- type: 'keyUp', key: 'Backspace', code: 'Backspace',
1552
- });
2004
+ await clearFieldAttached(tabId);
1553
2005
  await cdpSend(tabId, 'Input.insertText', { text: formatted });
1554
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1555
- type: 'keyDown', key: 'Tab', code: 'Tab',
1556
- });
1557
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1558
- type: 'keyUp', key: 'Tab', code: 'Tab',
1559
- });
2006
+ await tastParAttached(tabId, { key: 'Tab', code: 'Tab' }, { key: 'Tab', code: 'Tab' });
1560
2007
  } finally {
1561
2008
  await debuggerDetach(tabId);
1562
2009
  }
@@ -1715,12 +2162,7 @@ async function setDatePicker(tabId, selector, iso) {
1715
2162
  await debuggerAttach(tabId);
1716
2163
  try {
1717
2164
  const key = delta > 0 ? 'PageDown' : 'PageUp';
1718
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1719
- type: 'keyDown', key, code: key,
1720
- });
1721
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1722
- type: 'keyUp', key, code: key,
1723
- });
2165
+ await tastParAttached(tabId, { key, code: key }, { key, code: key });
1724
2166
  } finally {
1725
2167
  await debuggerDetach(tabId);
1726
2168
  }
@@ -2063,6 +2505,26 @@ async function setCombobox(tabId, selector, values, opts = {}) {
2063
2505
  const waitIterations = Math.max(1, Math.ceil(waitMs / 100));
2064
2506
  const results = [];
2065
2507
 
2508
+ // MAALT 9/9-2026: paa en aegte <select> brugte den her 8,5 sekunder paa at sige nej.
2509
+ // Den klikkede feltet, forsoegte at tomme det med en input-vaerdisaetter (en <select>
2510
+ // ER ikke et input), skrev tekst ind, og pollede saa 30 gange efter en listbox der
2511
+ // aldrig kan opstaa — for saa at svare "no-options-rendered". Kapaciteten fandtes hele
2512
+ // tiden i browser_select_option, som klarer samme felt paa 9 ms. Nu siger den det
2513
+ // med det samme i stedet for at lade agenten vente og gaette.
2514
+ const erNativeSelect = await debuggerEval(tabId, `(() => {
2515
+ const el = document.querySelector(${JSON.stringify(selector)});
2516
+ return el?.tagName === 'SELECT';
2517
+ })()`).catch(() => false);
2518
+ if (erNativeSelect) {
2519
+ return {
2520
+ ok: false,
2521
+ error: 'native-select',
2522
+ hint: 'Feltet er en almindelig <select>. Brug browser_select_option i stedet — ' +
2523
+ 'set_combobox er til dropdowns bygget af div/li med en listbox.',
2524
+ selector,
2525
+ };
2526
+ }
2527
+
2066
2528
  for (const val of valueList) {
2067
2529
  try {
2068
2530
  const inputEl = await resolveElement(tabId, selector);
@@ -2469,24 +2931,82 @@ async function dispatch(port, method, params) {
2469
2931
  if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:')) {
2470
2932
  throw new Error(`Cannot screenshot ${tab.url.split(':')[0]}: pages — navigate to a real page first`);
2471
2933
  }
2472
- // Capture without stealing focus: CDP Page.captureScreenshot (default → fromSurface:false
2473
- // retry) works for background/visible tabs; captureVisibleTab is the secondary.
2474
- const tryCapture = async () => {
2934
+ // MAALT 10/9, anden runde (Astra, reproduceret): reserveloesningen captureVisibleTab
2935
+ // fotograferer den fane der er SYNLIG i vinduet, ikke agentens. Et tjek foer og et efter
2936
+ // kunne ikke udelukke at brugeren skiftede A->B->A imens - og saa blev brugerens side
2937
+ // leveret. To tidspunkter beviser ikke hvad der skete imellem. Reserveloesningen er derfor
2938
+ // fjernet: CDP optager netop `tab.id`, eller der er intet billede.
2939
+ // Samme runde: en frist paa optagelsen startede alligevel en ny runde (haev vinduet, optag
2940
+ // igen), og kaeden kom over serverens 30 s. En frist markeres nu, og saa proeves der ikke igen.
2941
+ // Sign-off 11/9 (Astra, R5 F8): hele kaeden har ét budget (skaermbilledeFrister). Fristen gaelder ogsaa mens
2942
+ // cdpSend gentager et kald efter en afkobling - det var den gentagelse der bar kaeden over 30 s.
2943
+ const { foersteMs, samletMs, haevMs = 0 } = skaermbilledeFrister();
2944
+ const budgetSlut = Date.now() + samletMs;
2945
+ // MAALT 11/9 af Astra (e2e runde 2), tre fejl i den haevede runde: reservationen blev trukket fra IGEN (reserven fik
2946
+ // 0 ms), `debuggerAttach` laa uden for fristloebet (en gentilslutning paa 12,5 s bar kaeden over 30 s), og haevningen
2947
+ // startede nye optagelser og smed de igangvaerende vaek - 1.29.0 fik netop svar fra dem der allerede loeb.
2948
+ const igangvaerende = []; // alle optagelser der stadig kan svare, paa tvaers af runder
2949
+ const tryCapture = async ({ sidsteUdvej = false } = {}) => {
2950
+ const reserveret = sidsteUdvej ? 0 : haevMs; // kun foerste runde holder tid fri til haevningen
2951
+ const denneRunde = [];
2952
+ const optag = (p) => {
2953
+ const kald = cdpSend(tab.id, 'Page.captureScreenshot', p);
2954
+ kald.catch(() => {}); // et svar efter budgettet er ligegyldigt
2955
+ igangvaerende.push(kald);
2956
+ denneRunde.push(kald);
2957
+ return kald;
2958
+ };
2959
+ const medFrist = async (loefte, ms) => {
2960
+ let ur;
2961
+ try {
2962
+ return await Promise.race([
2963
+ loefte,
2964
+ new Promise((_, afvis) => {
2965
+ const frist = Math.max(0, ms);
2966
+ ur = setTimeout(() => afvis(new Error(`CDP svarede ikke inden ${frist} ms: Page.captureScreenshot`)), frist);
2967
+ }),
2968
+ ]);
2969
+ } catch (e) {
2970
+ if (e && typeof e === 'object' && /svarede ikke inden/.test(e.message || '')) e.ingenNyRunde = true;
2971
+ throw e;
2972
+ } finally {
2973
+ clearTimeout(ur);
2974
+ }
2975
+ };
2976
+ // Tilslutningen skal ogsaa ligge inde i budgettet: en gentilslutning kan tage lang tid paa et haevet vindue.
2977
+ await medFrist(debuggerAttach(tab.id), budgetSlut - reserveret - Date.now());
2978
+ const standard = optag({ format: 'png' });
2475
2979
  try {
2476
- await debuggerAttach(tab.id);
2980
+ const shot = await medFrist(standard, Math.min(foersteMs, budgetSlut - reserveret - Date.now()));
2981
+ return { image: 'data:image/png;base64,' + shot.data };
2982
+ } catch (foersteFejl) {
2983
+ // fromSurface:false proeves ogsaa efter en frist: i 1.29.0 var det netop fristen der naaede hertil, og den
2984
+ // leverede billedet. Men efter en frist startes ingen runde mere, uanset hvordan reserven fejler.
2985
+ // MAALT 11/9 af Astra (efterproevning af f084d1b): standardoptagelsen lykkedes efter 11 s, reserven fejlede, og
2986
+ // fristen paa 10 s havde kasseret standardbilledet. Standardoptagelsen loeber derfor videre efter sin frist, og
2987
+ // den af de to der lykkes foerst inden for budgettet, vinder.
2988
+ optag({ format: 'png', fromSurface: false, captureBeyondViewport: false });
2477
2989
  try {
2478
- const shot = await cdpSend(tab.id, 'Page.captureScreenshot', { format: 'png' });
2479
- return { image: 'data:image/png;base64,' + shot.data };
2480
- } catch {
2481
- const shot = await cdpSend(tab.id, 'Page.captureScreenshot', {
2482
- format: 'png', fromSurface: false, captureBeyondViewport: false,
2483
- });
2990
+ // Den haevede runde er sidste udvej paa en tildaekket skaerm og skal have tid tilbage (haevMs) - ellers naar
2991
+ // 1.29.0's eneste virkende vej aldrig frem (Fable, e2e 11/9).
2992
+ // MAALT 12/9 af Astra: alle igangvaerende optagelser laa i den SAMME Promise.any, saa et gammelt billede fra
2993
+ // foer haevningen kunne svare foerst og vinde - selv om siden havde aendret sig imens. Rundens EGNE optagelser
2994
+ // afgoer nu svaret; de aeldre taeller stadig med, men kun hvis rundens egne ikke naar frem.
2995
+ let shot;
2996
+ try {
2997
+ shot = await medFrist(Promise.any(denneRunde), budgetSlut - reserveret - Date.now());
2998
+ } catch (rundeFejl) {
2999
+ const aeldre = igangvaerende.filter((k) => !denneRunde.includes(k));
3000
+ if (!aeldre.length) throw rundeFejl;
3001
+ // Et aeldre billede der ALLEREDE er kommet, svarer i samme oejeblik - ogsaa naar budgettet er brugt op.
3002
+ shot = await medFrist(Promise.any(aeldre), budgetSlut - Date.now());
3003
+ }
2484
3004
  return { image: 'data:image/png;base64,' + shot.data };
3005
+ } catch (andenFejl) {
3006
+ const fejl = andenFejl instanceof AggregateError ? andenFejl.errors[andenFejl.errors.length - 1] : andenFejl;
3007
+ if (foersteFejl?.ingenNyRunde && fejl && typeof fejl === 'object') fejl.ingenNyRunde = true;
3008
+ throw fejl;
2485
3009
  }
2486
- } catch {
2487
- // CDP failed entirely — native tabs API (needs the tab visible in its window).
2488
- const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
2489
- return { image: dataUrl };
2490
3010
  }
2491
3011
  };
2492
3012
 
@@ -2494,6 +3014,13 @@ async function dispatch(port, method, params) {
2494
3014
  try {
2495
3015
  return await tryCapture();
2496
3016
  } catch (firstErr) {
3017
+ // 10/9 (Astra, R2) stod her: en frist betyder at kompositoren ikke svarede, saa en ny runde fordobler kun
3018
+ // ventetiden. MAALT 11/9 af Fable: paa en TILDAEKKET skaerm haenger begge optagelser netop indtil vinduet haeves -
3019
+ // 1.29.0 leverede dér et billede efter 16,7 s, hvor HEAD gav op. Budgettet (haevMs) holder kaeden under serverens
3020
+ // 30 s, saa den sidste udvej maa proeves uanset om fejlen var en frist.
3021
+ // Er budgettet brugt, er der ikke tid til en runde med haevet vindue foer serverens 30 s. (haevMs ovenfor er netop
3022
+ // den tid der blev holdt fri til den her runde.)
3023
+ if (budgetSlut - Date.now() <= 0) throw firstErr;
2497
3024
  // Both methods failed → the window is genuinely OCCLUDED (covered by other windows),
2498
3025
  // so Chrome's compositor produced no frames. LAST RESORT ONLY: raise the window to
2499
3026
  // de-occlude it, capture, then RESTORE the user's previously-focused window. This
@@ -2503,7 +3030,7 @@ async function dispatch(port, method, params) {
2503
3030
  await chrome.windows.update(tab.windowId, { focused: true, state: 'normal' });
2504
3031
  await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
2505
3032
  await new Promise(r => setTimeout(r, 250)); // let it composite
2506
- return await tryCapture();
3033
+ return await tryCapture({ sidsteUdvej: true }); // resten af budgettet - der er ikke en runde mere efter denne
2507
3034
  } catch (secondErr) {
2508
3035
  throw new Error(
2509
3036
  `Screenshot failed after focus-neutral AND raised attempts. ` +
@@ -2534,19 +3061,43 @@ async function dispatch(port, method, params) {
2534
3061
 
2535
3062
  const diag = { tried: [] };
2536
3063
 
3064
+ // MAALT 10/9 af Astra (anden runde), reproduceret med en taeller: kode der udfoerte en
3065
+ // effekt og SAA kastede, blev koert igen via debuggeren - to effekter. `sendt` beskyttede
3066
+ // kun debugger-loekken. Er koden koert, er dens fejl svaret; den koeres ikke igen.
3067
+ // Samme runde: scripting-stierne afventede ikke et Promise ("Promise.resolve(42)" gav {}).
3068
+ // Den injicerede funktion er nu async og afventer resultatet.
3069
+ const koertOgFejlede = (r) => ({
3070
+ ok: false, error: r.message, name: r.name,
3071
+ method: r.world === 'MAIN' ? 'scripting-main' : 'scripting-isolated',
3072
+ note: 'Koden KOERTE og kastede en fejl. Den koeres ikke igen via debuggeren, fordi det den ' +
3073
+ 'naaede at goere foer fejlen saa ville ske to gange.',
3074
+ });
3075
+ // MAALT 11/9 af Astra (R5 F4), reproduceret: scriptet sendte en POST og returnerede et
3076
+ // Promise; mens scripting-stien ventede, forsvandt dokumentet, og Chrome afviste med
3077
+ // "Frame with ID 0 was removed." Handleren gik saa videre til debuggeren, som koerte koden
3078
+ // igen - to POST'er. Forsvinder siden EFTER at koden er startet, kan den have koert, og
3079
+ // den koeres ikke igen. Blev den aldrig indsproejtet, maa debuggeren stadig proeve.
3080
+ const sidenForsvandt = (m) => /was removed|execution context was destroyed|document (was )?unloaded/i.test(m);
3081
+ const maaskeKoert = (world, m) => ({
3082
+ ok: false, error: m, maybe_ran: true,
3083
+ method: world === 'MAIN' ? 'scripting-main' : 'scripting-isolated',
3084
+ note: 'Siden skiftede eller lukkede mens koden koerte. Den kan allerede have koert, saa den ' +
3085
+ 'koeres ikke igen via debuggeren. Kald igen kun hvis det er sikkert at koere to gange.',
3086
+ });
2537
3087
  // Step 1: try ISOLATED world
2538
3088
  try {
2539
3089
  const [result] = await chrome.scripting.executeScript({
2540
3090
  target: { tabId: tab.id },
2541
3091
  world: 'ISOLATED',
2542
3092
  args: [params.code],
2543
- func: (codeStr) => {
2544
- try {
2545
- const fn = new Function('return (' + codeStr + ')');
2546
- return { __ok: true, value: fn() };
2547
- } catch (e) {
2548
- return { __scriptingError: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' };
2549
- }
3093
+ func: async (codeStr) => {
3094
+ // Oversaettelse og koersel er adskilt: fejler oversaettelsen (CSP, syntaks), koerte
3095
+ // intet, og debuggeren maa proeve. Fejler KOERSLEN, er koden allerede koert.
3096
+ let fn;
3097
+ try { fn = new Function('return (' + codeStr + ')'); }
3098
+ catch (e) { return { __kompilering: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' }; }
3099
+ try { return { __ok: true, value: await fn() }; }
3100
+ catch (e) { return { __scriptingError: true, koerte: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' }; }
2550
3101
  },
2551
3102
  });
2552
3103
  const r = result?.result;
@@ -2554,10 +3105,14 @@ async function dispatch(port, method, params) {
2554
3105
  if (r && typeof r === 'object' && r.__ok) {
2555
3106
  return { result: r.value, method: 'scripting-isolated' };
2556
3107
  }
2557
- if (r && typeof r === 'object' && r.__scriptingError) {
3108
+ if (r && typeof r === 'object' && r.__scriptingError && r.koerte) return koertOgFejlede(r);
3109
+ if (r && typeof r === 'object' && (r.__scriptingError || r.__kompilering)) {
2558
3110
  diag.isolated_error = r.message;
2559
3111
  }
2560
3112
  } catch (e) {
3113
+ // Ingen maybe_ran her. MAALT 11/9 i Chrome for Testing 153: `new Function` i ISOLATED afvises af udvidelsens
3114
+ // CSP ('unsafe-eval'), saa brugerens kode kan aldrig have koert i denne verden. Sign-off (Astra): en afvisning
3115
+ // med "Frame with ID 0 was removed." foer start gav maybe_ran og nul koersler, hvor 1.29.0 koerte koden via MAIN.
2561
3116
  diag.isolated_throw = String(e?.message || e);
2562
3117
  }
2563
3118
 
@@ -2567,13 +3122,14 @@ async function dispatch(port, method, params) {
2567
3122
  target: { tabId: tab.id },
2568
3123
  world: 'MAIN',
2569
3124
  args: [params.code],
2570
- func: (codeStr) => {
2571
- try {
2572
- const fn = new Function('return (' + codeStr + ')');
2573
- return { __ok: true, value: fn() };
2574
- } catch (e) {
2575
- return { __scriptingError: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' };
2576
- }
3125
+ func: async (codeStr) => {
3126
+ // Oversaettelse og koersel er adskilt: fejler oversaettelsen (CSP, syntaks), koerte
3127
+ // intet, og debuggeren maa proeve. Fejler KOERSLEN, er koden allerede koert.
3128
+ let fn;
3129
+ try { fn = new Function('return (' + codeStr + ')'); }
3130
+ catch (e) { return { __kompilering: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' }; }
3131
+ try { return { __ok: true, value: await fn() }; }
3132
+ catch (e) { return { __scriptingError: true, koerte: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' }; }
2577
3133
  },
2578
3134
  });
2579
3135
  const r = result?.result;
@@ -2581,11 +3137,14 @@ async function dispatch(port, method, params) {
2581
3137
  if (r && typeof r === 'object' && r.__ok) {
2582
3138
  return { result: r.value, method: 'scripting-main' };
2583
3139
  }
2584
- if (r && typeof r === 'object' && r.__scriptingError) {
3140
+ if (r && typeof r === 'object' && r.__scriptingError && r.koerte) return koertOgFejlede(r);
3141
+ if (r && typeof r === 'object' && (r.__scriptingError || r.__kompilering)) {
2585
3142
  diag.main_error = r.message;
2586
3143
  }
2587
3144
  } catch (e) {
2588
- diag.main_throw = String(e?.message || e);
3145
+ const m = String(e?.message || e);
3146
+ if (sidenForsvandt(m)) return maaskeKoert('MAIN', m);
3147
+ diag.main_throw = m;
2589
3148
  }
2590
3149
 
2591
3150
  // Step 3: debugger fallback — the ONLY universal path for arbitrary STRING code
@@ -2597,10 +3156,15 @@ async function dispatch(port, method, params) {
2597
3156
  // fires and debuggerEval silently returned undefined → the caller saw a bare
2598
3157
  // `{method:"debugger"}` with no result. Also surface script exceptions + raw
2599
3158
  // diagnostics so a genuine failure is never mistaken for an empty success.
2600
- let rawDbg, dbgErr = '';
3159
+ let rawDbg, dbgErr = '', sendt = false;
2601
3160
  for (let attempt = 0; attempt < 4; attempt++) {
2602
3161
  try {
2603
3162
  await debuggerAttach(tab.id);
3163
+ // MAALT 10/9 af Astra: faldt debuggeren af EFTER at scriptet var sendt, loeb loekken
3164
+ // videre og koerte BRUGERENS kode igen — op til fire gange. Vi kan ikke se om et
3165
+ // vilkaarligt script muterer. Samme regel som for Runtime.evaluate i cdpSend: er det
3166
+ // sendt, gentages det ikke automatisk. Kun en fejl FOER afsendelse proeves igen.
3167
+ sendt = true;
2604
3168
  rawDbg = await cdpSend(tab.id, 'Runtime.evaluate', {
2605
3169
  expression: '(' + params.code + '\n)',
2606
3170
  returnByValue: true,
@@ -2616,12 +3180,14 @@ async function dispatch(port, method, params) {
2616
3180
  return { result: rawDbg.result.value, method: 'debugger' };
2617
3181
  }
2618
3182
  dbgErr = 'empty/undefined CDP response: ' + JSON.stringify(rawDbg);
3183
+ break; // tomt svar efter afsendelse: scriptet kan have koert — gentag ikke
2619
3184
  } catch (e) {
2620
3185
  const m = String(e?.message || e);
2621
3186
  if (m.startsWith('__SCRIPT_EX__')) {
2622
3187
  throw new Error(m.slice('__SCRIPT_EX__'.length) + ' | scripting-diag: ' + JSON.stringify(diag));
2623
3188
  }
2624
3189
  dbgErr = m;
3190
+ if (sendt) break; // sendt = maaske koert — se kommentaren ved afsendelsen
2625
3191
  if (!/detach|attach|empty|gone|given id|not attached/i.test(m)) break;
2626
3192
  }
2627
3193
  await debuggerDetach(tab.id).catch(() => {});
@@ -2629,6 +3195,8 @@ async function dispatch(port, method, params) {
2629
3195
  }
2630
3196
  throw new Error(
2631
3197
  'execute_script failed on all paths. debugger: ' + dbgErr +
3198
+ (sendt ? ' | NB: scriptet blev sendt til siden foer det fejlede og KAN allerede have koert. ' +
3199
+ 'Det gentages ikke automatisk — kald igen kun hvis det er sikkert at koere to gange.' : '') +
2632
3200
  ' | raw: ' + JSON.stringify(rawDbg) +
2633
3201
  ' | scripting-diag: ' + JSON.stringify(diag)
2634
3202
  );
@@ -2659,7 +3227,6 @@ async function dispatch(port, method, params) {
2659
3227
  // Primary path: debugger mouse events (isTrusted=true, works on React/Angular SPAs)
2660
3228
  const clickResult = await debuggerClick(tab.id, el.x, el.y);
2661
3229
  return {
2662
- ok: true,
2663
3230
  method: el.method || 'debugger',
2664
3231
  tag: el.tag,
2665
3232
  text: el.text,
@@ -2667,6 +3234,15 @@ async function dispatch(port, method, params) {
2667
3234
  // saa `click` svarede ok:true selv naar siden slet ikke reagerede. Nu foelger den med:
2668
3235
  // landed=false betyder "eventet blev sendt, men intet handler tog imod det".
2669
3236
  ...(clickResult || {}),
3237
+ // MAALT 9/9 (issue #19, fjerde gang samme fejlklasse efter select_option og fill):
3238
+ // `ok: true` stod hardkodet, og `landed` blev spredt ind bagefter. Klikket svarede
3239
+ // altsaa ja og nej i samme aandedrag, og en agent laeser `ok`.
3240
+ // Et element der forsvandt ER en virkning — derfor tæller `detached` som landet.
3241
+ // MAALT 12/9 af Astra (N1): `ok` stod FOER settle-vaerdien her og i click_xy, hvor select_option var immun -
3242
+ // saa et svar fra siden kunne bestemme vaerktoejets egen dom. Ikke naabart i dag, men det er samme klasse som
3243
+ // lige blev lukket for noten, og de tre steder var indbyrdes uens. Vaerktoejets vurdering staar nu sidst.
3244
+ ok: klikLandede(clickResult),
3245
+ ...(uvisVurdering(clickResult) || {}),
2670
3246
  };
2671
3247
  } catch (e) {
2672
3248
  // Fallback: synthetic click via chrome.scripting for anti-automation sites
@@ -2686,9 +3262,46 @@ async function dispatch(port, method, params) {
2686
3262
  //
2687
3263
  // Prisen ved fallbacken er at klikket mister isTrusted=true. Det tjekker de færreste
2688
3264
  // 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 || '')) {
3265
+ // MAALT 10/9 af Astra (anden runde), reproduceret: museknappen var sendt ned og op, CDP meldte
3266
+ // derefter afkobling - og reserveloesningen klikkede EN GANG TIL. To effekter, ok:true. Er
3267
+ // trykket sendt, kan klikket vaere landet; saa klikkes der ikke igen.
3268
+ if (e?.trykSendt) {
3269
+ return {
3270
+ ok: false, error: e.message, maaske_landet: true, method: 'debugger',
3271
+ note: 'Museklikket blev sendt, men debuggeren koblede fra bagefter. Klikket KAN vaere landet, ' +
3272
+ 'saa det gentages ikke. Tjek siden foer du klikker igen.',
3273
+ };
3274
+ }
3275
+ // MAALT 11/9 i Chrome for Testing: en fane i baggrunden faar ikke Input.* - musebevaegelsen udloeber FOER
3276
+ // trykket er sendt. Intet klik kan vaere landet (trykSendt er falsk, se ovenfor), saa script-klikket er sikkert.
3277
+ const inputFristFoerTryk = /svarede ikke inden \d+ ms: Input\./.test(e?.message || '');
3278
+ if (inputFristFoerTryk || /Debugger detached|Debugger attach failed|not attached/i.test(e?.message || '')) {
3279
+ // Ingen "ny adresse = klikket navigerede"-regel her. Astra (efterproevning af c1496d4): en UAFHAENGIG navigation
3280
+ // fjernede rammen foer scriptet koerte, og reglen svarede ok:true med nul handlinger. En afvisning beviser ikke at
3281
+ // scriptet koerte, og en ny adresse beviser ikke at det var klikket.
2690
3282
  const r = await scriptingClick(tab.id, params.selector);
2691
- if (r.ok) return { ok: true, method: 'scripting-fallback', tag: r.tag };
3283
+ if (r.ok && !inputFristFoerTryk) {
3284
+ // Debuggeren var blokeret eller afkoblet: samme svar som 1.29.0. landed vedlaegges kun naar klikket er bevist - Astra
3285
+ // (efterproevning af c826f63): en menu der aabnede paa mousedown, blev ellers meldt som landed:false.
3286
+ return { ok: true, method: 'scripting-fallback', tag: r.tag, ...(klikLandede(r) ? { landed: true } : {}) };
3287
+ }
3288
+ if (r.ok) {
3289
+ // Sign-off 11/9 (Astra og Fable): paa en baggrundsfane svarede reserven ok:true, ogsaa naar siden intet gjorde
3290
+ // (handler der kraever isTrusted). 1.29.0 svarede med en fejl. Nu kraever ok samme bevis som de andre klikveje.
3291
+ // Viste siden ingen reaktion, er klikket alligevel sendt - saa det meldes som "kan vaere landet", ikke gentag blindt.
3292
+ if (klikLandede(r)) {
3293
+ return {
3294
+ ok: true, method: 'scripting-fallback', tag: r.tag, landed: true,
3295
+ note: 'Fanen var i baggrunden, saa musehaendelser naaede ikke frem. Klikket blev udfoert med et script i stedet.',
3296
+ };
3297
+ }
3298
+ return {
3299
+ ok: false, method: 'scripting-fallback', tag: r.tag, landed: false, maaske_landet: true, error: e.message,
3300
+ note: 'Fanen var i baggrunden, saa musehaendelser naaede ikke frem. Et script-klik blev sendt, men klikket selv ' +
3301
+ 'gav ingen synlig virkning: enten kraever siden et aegte klik, eller klikket virkede uden synlig aendring. Tjek siden, ' +
3302
+ 'og kald browser_switch_tab og klik igen kun hvis intet skete.',
3303
+ };
3304
+ }
2692
3305
  }
2693
3306
  throw e;
2694
3307
  }
@@ -2714,6 +3327,27 @@ async function dispatch(port, method, params) {
2714
3327
  await debuggerFill(tab.id, parsed.selector, params.value);
2715
3328
  return { ok: true, method: 'debugger' };
2716
3329
  } catch (e) {
3330
+ // MAALT 10/9 af Astra: debugger-vejen timede ud, og reserveloesningen skrev saa hele
3331
+ // vaerdien med den native setter. Men Promise.race afbryder ikke — tastetrykkene fra
3332
+ // debugger-forsoeget kan lande BAGEFTER. Reproduceret: "X" blev til "XX", og kaldet
3333
+ // svarede ok:true. To regler: er vaerdien der allerede efter en frist, er vi faerdige;
3334
+ // og efter reserveloesningen laeses feltet igen, saa en fordobling ses i stedet for
3335
+ // at blive meldt som succes.
3336
+ const laesFelt = async () => {
3337
+ const r = await safeExecuteScript(tab.id, (sel) => {
3338
+ const el = document.querySelector(sel);
3339
+ return el && 'value' in el ? el.value : null;
3340
+ }, [parsed.selector]).catch(() => null);
3341
+ return r && !r.cspBlocked ? r.result : null;
3342
+ };
3343
+ const fristUdloeb = /svarede ikke inden/.test(e?.message || '');
3344
+ if (fristUdloeb) await new Promise((r) => setTimeout(r, 400));
3345
+ // MAALT 11/9 af Astra (R5 F1): feltet laeses FOER reserveloesningen skriver. Det er det der skiller en
3346
+ // side der afviste vaerdien (feltet stod stille) fra en side der formaterede den (feltet aendrede sig).
3347
+ const foer = await laesFelt();
3348
+ if (fristUdloeb && foer === params.value) {
3349
+ return { ok: true, method: 'debugger', note: 'landede trods fristen' };
3350
+ }
2717
3351
  // Fallback to executeScript if debugger fails
2718
3352
  const scriptResult = await safeExecuteScript(tab.id, (sel, val) => {
2719
3353
  const el = document.querySelector(sel);
@@ -2728,8 +3362,59 @@ async function dispatch(port, method, params) {
2728
3362
  el.dispatchEvent(new Event('change', { bubbles: true }));
2729
3363
  return { ok: true };
2730
3364
  }, [parsed.selector, params.value]);
2731
- if (!scriptResult.cspBlocked) return scriptResult.result;
2732
- return { ok: false, error: e.message, method: 'debugger' };
3365
+ if (scriptResult.cspBlocked) return { ok: false, error: e.message, method: 'debugger' };
3366
+ if (!scriptResult.result?.ok) return scriptResult.result;
3367
+ await new Promise((r) => setTimeout(r, 300));
3368
+ const endelig = await laesFelt();
3369
+ // Kun to sluttilstande er fejl. FORDOBLET: et forsinket Input.insertText landede efter
3370
+ // setteren ("X" -> "XX", reproduceret). TOEMT: et forsinket Cmd+A/Backspace ryddede feltet
3371
+ // igen. Alt andet — "5" der bliver til "5,00 kr", et telefonnummer med mellemrum — er
3372
+ // feltets egen formatering og maa ikke meldes som fejl.
3373
+ const v = String(params.value ?? '');
3374
+ if (typeof endelig === 'string' && endelig !== v) {
3375
+ const fordoblet = endelig === v + v || (v.length >= 3 && endelig.includes(v + v));
3376
+ if (fordoblet || (v && endelig === '')) {
3377
+ return {
3378
+ ok: false, method: 'fallback', error: fordoblet ? 'feltet-fordoblet' : 'feltet-toemt',
3379
+ forventet: v, faktisk: endelig,
3380
+ note: 'Et forsinket tastetryk fra debugger-forsoeget landede efter reserveloesningen.',
3381
+ };
3382
+ }
3383
+ // Anden runde: "OLD" efter fill("NEW") blev kaldt formatering. Tredje og fjerde runde: hver regel for
3384
+ // "det er bare formatering" havde huller ("5" -> "15", "1.5" -> "15", Unicode-minus, "+45" -> "+1 45").
3385
+ // Femte runde (R5 F1): at melde ENHVER afvigelse som fejl gjorde korrekt formatering ("1.234,50 kr",
3386
+ // "+45 12 34 56 78") til ok:false, hvor 1.29.0 sagde ok. Skellet maales nu i stedet for at gaettes:
3387
+ // stod feltet stille (foer === efter) -> uvist, og det siges: afviger + uaendret (se nedenfor)
3388
+ // aendrede det sig til noget andet -> ok:true, men afviger:true med den faktiske vaerdi
3389
+ // Kalderen faar altsaa aldrig en tavs succes paa en anden vaerdi end den der blev skrevet.
3390
+ // Sign-off 11/9 (Astra og Fable, begge reproduceret): feltet viste allerede "1.234,50 kr", fill("1234.5")
3391
+ // blev formateret tilbage til praecis det samme, og svaret var "siden tog ikke imod vaerdien" (1.29.0: ok).
3392
+ // Foer = efter kan ikke skelne en afvisning fra en vaerdi der allerede stod der i sidens format.
3393
+ // Kun en toemning der ikke skete er entydig: intet format goer "" til noget andet.
3394
+ if (typeof foer === 'string' && endelig === foer) {
3395
+ if (!v) {
3396
+ return {
3397
+ ok: false, method: 'fallback', error: 'feltet-viser-andet', forventet: v, faktisk: endelig,
3398
+ note: 'Feltet skulle toemmes, men viser det samme som foer.',
3399
+ };
3400
+ }
3401
+ return {
3402
+ ok: true, method: 'fallback', value: endelig, afviger: true, uaendret: true, forventet: v, faktisk: endelig,
3403
+ note: 'Feltet viste det samme foer og efter skrivningen. Enten stod vaerdien der allerede i sidens eget ' +
3404
+ 'format, eller siden tog ikke imod den. Tjek `faktisk` foer du gaar videre.',
3405
+ };
3406
+ }
3407
+ return {
3408
+ ok: true, method: 'fallback', value: endelig, afviger: true, forventet: v, faktisk: endelig,
3409
+ ...(typeof foer === 'string' ? {} : { foer_ukendt: true }),
3410
+ note: 'Feltet aendrede sig, men viser en anden tekst end den der blev skrevet - fx formatering ' +
3411
+ '("5,00 kr", "+45 12 34 56 78") eller en afkortning. Tjek `faktisk`, hvis den praecise vaerdi betyder noget.',
3412
+ };
3413
+ }
3414
+ return {
3415
+ ok: true, method: 'fallback', value: endelig ?? v,
3416
+ ...(typeof endelig === 'string' ? {} : { verificeret: false }),
3417
+ };
2733
3418
  }
2734
3419
  }
2735
3420
 
@@ -2764,9 +3449,16 @@ async function dispatch(port, method, params) {
2764
3449
  await new Promise(r => setTimeout(r, 250));
2765
3450
  const v = await readBackValue(tab.id, params.selector);
2766
3451
  tried.push({ path: 'masked', format: fmt.order.join(fmt.sep), value: v });
2767
- if (valueLooksLikeIso(v, iso)) return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep) };
3452
+ if (valueLooksLikeIso(v, iso, fmt)) return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep) };
2768
3453
  } catch (e) {
2769
- tried.push({ path: 'masked', error: e.message });
3454
+ // MAALT 10/9 af Astra: en frist her betyder ikke at intet skete. Tastetrykkene kan
3455
+ // allerede staa i feltet, og saa ville kalender-vejen nedenfor saette datoen EN GANG
3456
+ // TIL. Laes feltet foer vi proever noget andet.
3457
+ const v = await readBackValue(tab.id, params.selector).catch(() => null);
3458
+ tried.push({ path: 'masked', error: e.message, value: v });
3459
+ if (valueLooksLikeIso(v, iso, fmt)) {
3460
+ return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep), note: 'landede trods fejl i afsendelsen' };
3461
+ }
2770
3462
  }
2771
3463
  } else {
2772
3464
  tried.push({
@@ -2787,11 +3479,13 @@ async function dispatch(port, method, params) {
2787
3479
 
2788
3480
  // Path C: calendar-picker navigation
2789
3481
  if (!params.skip_picker) {
3482
+ // Fjerde runde (Astra): kalender-grenen glemte feltets format, saa 01/12/2026 blev godkendt som 12. januar.
3483
+ const kendtFormat = parsePlaceholderFormat(info.placeholder) || parsePlaceholderFormat(info.ariaLabel);
2790
3484
  const r = await setDatePicker(tab.id, params.selector, iso);
2791
3485
  await new Promise(r2 => setTimeout(r2, 200));
2792
3486
  const v = await readBackValue(tab.id, params.selector);
2793
3487
  tried.push({ path: 'picker', ...r, value: v });
2794
- if (r.ok && valueLooksLikeIso(v, iso)) return { ok: true, method: 'picker', value: v, navAttempts: r.navAttempts };
3488
+ if (r.ok && valueLooksLikeIso(v, iso, kendtFormat)) return { ok: true, method: 'picker', value: v, navAttempts: r.navAttempts };
2795
3489
  }
2796
3490
 
2797
3491
  const visibleErrors = await collectVisibleErrors(tab.id, params.selector);
@@ -2888,26 +3582,44 @@ async function dispatch(port, method, params) {
2888
3582
  const vkCode = VK_CODES[key];
2889
3583
  const vkParams = vkCode ? { windowsVirtualKeyCode: vkCode, nativeVirtualKeyCode: vkCode } : {};
2890
3584
 
3585
+ // MAALT 10/9 af Astra: keyDown og keyUp stod i samme try. Timede keyDown ud — og det
3586
+ // kan det, selv naar tasten LANDEDE (Enter der sender en formular) — blev keyUp aldrig
3587
+ // sendt. En tast der kun er trykket ned, er en tast der haenger. Nu sendes keyUp altid,
3588
+ // og svaret siger om nedtrykket fejlede i stedet for at kaste raat.
2891
3589
  await debuggerAttach(tab.id);
3590
+ let tastFejl = null;
2892
3591
  try {
2893
- await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
2894
- type: 'keyDown',
2895
- key,
2896
- code: params.code || key,
2897
- modifiers,
2898
- text: key.length === 1 ? key : '',
2899
- ...vkParams,
2900
- });
2901
- await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
2902
- type: 'keyUp',
2903
- key,
2904
- code: params.code || key,
2905
- modifiers,
2906
- ...vkParams,
2907
- });
3592
+ try {
3593
+ await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
3594
+ type: 'keyDown',
3595
+ key,
3596
+ code: params.code || key,
3597
+ modifiers,
3598
+ text: key.length === 1 ? key : '',
3599
+ ...vkParams,
3600
+ });
3601
+ } catch (e) { tastFejl = e; }
3602
+ try {
3603
+ await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
3604
+ type: 'keyUp',
3605
+ key,
3606
+ code: params.code || key,
3607
+ modifiers,
3608
+ ...vkParams,
3609
+ });
3610
+ } catch (e) { if (!tastFejl) tastFejl = e; }
2908
3611
  } finally {
2909
3612
  await debuggerDetach(tab.id);
2910
3613
  }
3614
+ if (tastFejl) {
3615
+ const frist = /svarede ikke inden/.test(tastFejl.message || '');
3616
+ return {
3617
+ ok: false, key, error: tastFejl.message,
3618
+ ...(frist ? { maaske_landet: true,
3619
+ note: 'Chrome kvitterede ikke inden fristen. Tasten kan alligevel have virket ' +
3620
+ '(fx en formular der blev sendt) — tjek siden foer du trykker igen.' } : {}),
3621
+ };
3622
+ }
2911
3623
  return { ok: true, key };
2912
3624
  }
2913
3625
 
@@ -2926,6 +3638,14 @@ async function dispatch(port, method, params) {
2926
3638
  // FB/Twitter/IG only trigger lazy-load on continuous wheel events, not a single large delta.
2927
3639
  const dx = params.x || 0;
2928
3640
  const dy = params.y || 0;
3641
+ // Hvor stod siden FOER vi roerte den? Uden det tal kan reserveloesningen ikke vide
3642
+ // hvor meget hjulet naaede, og ender med at rulle for langt.
3643
+ // MAALT 10/9: her stod `.catch(() => ({x:0,y:0}))`. Et opdigtet nulpunkt er vaerre end
3644
+ // ingen: stod siden paa 500 og laesningen fejlede, ville reserveloesningen rulle OP.
3645
+ const start = await debuggerEval(tab.id, '({x: window.scrollX, y: window.scrollY})')
3646
+ .catch(() => null);
3647
+ const startKendt = !!start && typeof start.y === 'number';
3648
+ const startX = startKendt ? start.x : 0, startY = startKendt ? start.y : 0;
2929
3649
  try {
2930
3650
  await debuggerAttach(tab.id);
2931
3651
  const STEP_SIZE = 300; // pixels per wheel-event (matches a typical mouse-wheel notch)
@@ -2943,9 +3663,70 @@ async function dispatch(port, method, params) {
2943
3663
  // before any subsequent commands run (caller often scrapes immediately after)
2944
3664
  await new Promise(r => setTimeout(r, 600));
2945
3665
  } catch (e) {
2946
- // Fallback to window.scrollBy for simple pages (synthetic but works on non-anti-scrape sites)
2947
- await debuggerEval(tab.id, `window.scrollBy(${dx}, ${dy})`);
2948
- return { ok: true, scrolled: { x: dx, y: dy }, method: 'fallback', fallback_reason: e.message };
3666
+ // MAALT 9/9 af reviewet: her stod `window.scrollBy(dx, dy)` altsaa "rul det HELE igen".
3667
+ // Promise.race afbryder ikke det kald den opgiver, saa hjultrin der allerede virkede
3668
+ // bliver liggende. Reproduceret: scroll({y:600}), foerste trin flyttede 300 uden at
3669
+ // kvittere, fallbacken lagde 600 oveni = 900 faktisk, 600 rapporteret.
3670
+ // scrollTo mod en beregnet MAAL-position er idempotent: har hjulet allerede rullet
3671
+ // halvdelen, ruller vi kun resten.
3672
+ // MAALT 10/9 af Astra, i MIN egen rettelse fra fire timer foer: her stod
3673
+ // `.catch(() => null)` og derefter `ok: true` ubetinget. Fejlede ogsaa
3674
+ // reserveloesningen, svarede vaerktoejet succes med nul rullede pixels.
3675
+ // Femte gang samme fejlklasse paa én dag — og den her var min.
3676
+ // MAALT 10/9 af Astra (anden runde), reproduceret: kunne starten ikke laeses, blev der
3677
+ // rullet RELATIVT - og hjultrin der allerede var landet blev lagt oveni (500 -> 1400 ved
3678
+ // y:600). Flaget start_ukendt dokumenterede risikoen uden at forhindre den. Uden kendt
3679
+ // start findes ingen rulning der kan gentages uden at rulle dobbelt, saa der rulles ikke.
3680
+ if (!startKendt) {
3681
+ return {
3682
+ ok: false, method: 'fallback', error: 'scroll-uvist', start_ukendt: true, hjul_fejl: e.message,
3683
+ hint: 'Hjul-kaldet svarede ikke, og startpositionen kunne ikke laeses, saa siden KAN have ' +
3684
+ 'rullet. Laes window.scrollY med browser_execute_script, og rul derefter det der mangler.',
3685
+ };
3686
+ }
3687
+ const landede = await debuggerEval(tab.id, `(() => {
3688
+ const foer = { x: window.scrollX, y: window.scrollY };
3689
+ window.scrollTo(${startX} + ${dx}, ${startY} + ${dy});
3690
+ return { foer, efter: { x: window.scrollX, y: window.scrollY } };
3691
+ })()`).catch((fejl) => ({ fejl: fejl?.message || String(fejl) }));
3692
+
3693
+ if (!landede || landede.fejl) {
3694
+ return {
3695
+ ok: false, method: 'fallback', error: 'scroll-mislykkedes',
3696
+ hjul_fejl: e.message,
3697
+ fallback_fejl: landede?.fejl || 'reserveloesningen svarede ikke',
3698
+ };
3699
+ }
3700
+ // MAALT 11/9 af Astra (e2e-review): med blød rulning (scroll-behavior: smooth) naar siden foerst maalet over de naeste
3701
+ // billeder. Laest i samme oejeblik blev en rulning der lykkedes meldt som "bunden er maaske naaet" (1.29.0: ok).
3702
+ // Positionen laeses igen hvert 100 ms, til maalet er naaet eller siden staar stille - hoejst ca. 1 s.
3703
+ // Astra (efterproevning af c826f63): bundet af et ANTAL forsoeg kom en side med langsomme opslag over serverens 30 s
3704
+ // (1.29.0: svar efter 3 s). Genlaesningen er bundet af tid.
3705
+ const roSlut = Date.now() + 1000;
3706
+ while (Date.now() < roSlut && !(landede.efter.x === startX + dx && landede.efter.y === startY + dy)) {
3707
+ await new Promise((r) => setTimeout(r, 100));
3708
+ const nu = await debuggerEval(tab.id, '({x: window.scrollX, y: window.scrollY})').catch(() => null);
3709
+ if (!nu || typeof nu.y !== 'number') break;
3710
+ const stille = nu.x === landede.efter.x && nu.y === landede.efter.y;
3711
+ landede.efter = nu;
3712
+ if (stille) break;
3713
+ }
3714
+ const flyttede = landede.efter.x !== landede.foer.x || landede.efter.y !== landede.foer.y;
3715
+ const alleredeFremme = !flyttede && startKendt &&
3716
+ landede.efter.x === startX + dx && landede.efter.y === startY + dy;
3717
+ // Astra (efterproevning af cf5b98a): en animation der gik frem, tilbage og foerst naaede maalet efter 1,6 s, fik
3718
+ // svaret ok:false "siden flyttede sig ikke" (1.29.0: ok:true). En rulning der ER sendt, meldes aldrig som fiasko.
3719
+ // Kan bevaegelsen ikke ses inden for ventetiden, er svaret uvist - samme aerlighed som maaske_landet paa klik.
3720
+ return {
3721
+ ok: true,
3722
+ method: 'fallback', fallback_reason: e.message,
3723
+ position: landede.efter, foer: landede.foer,
3724
+ ...(flyttede || alleredeFremme ? {} : {
3725
+ uvist: true,
3726
+ note: 'Rulningen blev sendt, men positionen var uaendret da vi svarede: enten er en blød rulning stadig i gang, ' +
3727
+ 'eller bunden er naaet. Laes window.scrollY med browser_execute_script hvis den praecise position betyder noget.',
3728
+ }),
3729
+ };
2949
3730
  }
2950
3731
  return { ok: true, scrolled: { x: dx, y: dy }, method: 'mouseWheel-stepped' };
2951
3732
  }
@@ -2996,8 +3777,30 @@ async function dispatch(port, method, params) {
2996
3777
  if (typeof params.x !== 'number' || typeof params.y !== 'number') {
2997
3778
  return { ok: false, error: 'x and y (numbers, CSS pixels in viewport) are required' };
2998
3779
  }
2999
- await debuggerClick(tab.id, params.x, params.y);
3000
- return { ok: true, clicked_at: { x: params.x, y: params.y } };
3780
+ // MAALT 10/9: debuggerClick maaler om klikket landede, men svaret blev smidt vaek og
3781
+ // `ok: true` stod hardkodet samme fejl som `click` havde (issue #19). Samme regel her.
3782
+ let klik;
3783
+ try {
3784
+ klik = await debuggerClick(tab.id, params.x, params.y);
3785
+ } catch (e) {
3786
+ // Samme regel som click (Astra, tredje runde): er museknappen sendt, kan klikket vaere landet -
3787
+ // og en kastet fejl mister markeringen over forbindelsen.
3788
+ if (e?.trykSendt) {
3789
+ return {
3790
+ ok: false, error: e.message, maaske_landet: true, clicked_at: { x: params.x, y: params.y },
3791
+ note: 'Museklikket blev sendt, men debuggeren fejlede bagefter. Klikket KAN vaere landet, ' +
3792
+ 'saa det gentages ikke. Tjek siden foer du klikker igen.',
3793
+ };
3794
+ }
3795
+ throw e;
3796
+ }
3797
+ return {
3798
+ clicked_at: { x: params.x, y: params.y },
3799
+ ...(klik || {}),
3800
+ // Samme som click: vaerktoejets egen dom og et uvist klik staar sidst, saa siden ikke kan overskrive dem.
3801
+ ok: klikLandede(klik),
3802
+ ...(uvisVurdering(klik) || {}),
3803
+ };
3001
3804
  }
3002
3805
 
3003
3806
  case 'reattach_debugger': {
@@ -3052,6 +3855,20 @@ async function dispatch(port, method, params) {
3052
3855
  // INTET, og svaret sagde stadig at det var lykkedes. Samme fejlklasse som klikket
3053
3856
  // der svarede ok:true uden at siden reagerede. Nu laeses svaret, og der laeses
3054
3857
  // TILBAGE fra feltet bagefter, saa "valgt" betyder at vaerdien faktisk staar der.
3858
+ // MAALT 8/9 paa forbrugeragenten.dk/penge-tilbage: her laa en anden fejl af samme
3859
+ // familie. Vagten laeste `sel.value` SYNKRONT lige efter dispatch og kaldte enhver
3860
+ // afvigelse "rullet tilbage". Men et React-styret felt der ARBEJDER ser praecis
3861
+ // saadan ud: onChange koerer, komponenten gemmer valget et andet sted og nulstiller
3862
+ // sin egen `value`. Vi maalte altsaa succes som fiasko — og sagde ok:false om et
3863
+ // valg der faktisk landede (chippen "Norlys Energi ×" stod paa siden bagefter).
3864
+ //
3865
+ // Det er den omvendte udgave af issue #19, og rettelsen er den samme som issuet
3866
+ // beder om: maal EFFEKTEN, ikke feltet. Aendrede resten af siden sig, gjorde
3867
+ // komponenten sit arbejde — uanset hvad feltet staar paa nu.
3868
+ const aftryk = `(function(el){
3869
+ return el.options.length + '|' + (el.form ? el.form.innerText.length : document.body.innerText.length);
3870
+ })(document.querySelector(${JSON.stringify(params.selector)}))`;
3871
+
3055
3872
  const valg = await debuggerEval(tab.id, `
3056
3873
  (function() {
3057
3874
  const sel = document.querySelector(${JSON.stringify(params.selector)});
@@ -3064,20 +3881,44 @@ async function dispatch(port, method, params) {
3064
3881
  return JSON.stringify({ found: false, error: 'Ingen mulighed matchede: ' + oensket,
3065
3882
  available: Array.from(sel.options).map(o => o.text.trim()).slice(0, 25) });
3066
3883
  }
3884
+ const foer = ${aftryk};
3067
3885
  sel.value = opt.value;
3068
3886
  sel.dispatchEvent(new Event('input', { bubbles: true }));
3069
3887
  sel.dispatchEvent(new Event('change', { bubbles: true }));
3070
- return JSON.stringify({ found: true, wanted: opt.value, actual: sel.value, text: opt.text.trim() });
3888
+ return JSON.stringify({ found: true, wanted: opt.value, actual: sel.value, text: opt.text.trim(), foer });
3071
3889
  })()
3072
3890
  `);
3073
3891
  let r; try { r = JSON.parse(valg); } catch { r = null; }
3074
3892
  if (!r) return { ok: false, type: 'native_select', error: 'Kunne ikke laese resultatet af valget' };
3075
3893
  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}"` };
3894
+
3895
+ if (r.actual === r.wanted) return { ok: true, type: 'native_select', selected: r.text, value: r.actual };
3896
+
3897
+ // Feltet holder ikke vaerdien. Giv rammen tid til at gen-rendere, og se saa efter
3898
+ // om NOGET andet aendrede sig. Gjorde det det, blev valget taget imod.
3899
+ await new Promise((res) => setTimeout(res, 150));
3900
+ const efter = await debuggerEval(tab.id, `
3901
+ (function() {
3902
+ const sel = document.querySelector(${JSON.stringify(params.selector)});
3903
+ if (!sel) return JSON.stringify({ vaerdi: null, aftryk: null });
3904
+ return JSON.stringify({ vaerdi: sel.value, aftryk: ${aftryk} });
3905
+ })()
3906
+ `);
3907
+ let e; try { e = JSON.parse(efter); } catch { e = null; }
3908
+
3909
+ if (e && e.vaerdi === r.wanted) {
3910
+ return { ok: true, type: 'native_select', selected: r.text, value: e.vaerdi };
3911
+ }
3912
+ if (e && e.aftryk && r.foer && e.aftryk !== r.foer) {
3913
+ return {
3914
+ ok: true, type: 'native_select', selected: r.text, value: e.vaerdi,
3915
+ note: `Feltet nulstillede sig selv til "${e.vaerdi}", men siden reagerede — ` +
3916
+ 'et styret felt der gemmer valget et andet sted. Valget landede.',
3917
+ };
3079
3918
  }
3080
- return { ok: true, type: 'native_select', selected: r.text, value: r.actual };
3919
+ return { ok: false, type: 'native_select',
3920
+ error: `Valget blev rullet tilbage: satte "${r.wanted}", feltet staar paa "${e ? e.vaerdi : r.actual}", ` +
3921
+ 'og intet andet paa siden aendrede sig.' };
3081
3922
  }
3082
3923
 
3083
3924
  // Custom dropdown (Angular Material, React Select, etc.)
@@ -3105,12 +3946,15 @@ async function dispatch(port, method, params) {
3105
3946
  // ordret "it never reports success without the field actually changing".
3106
3947
  return {
3107
3948
  ...(valgKlik || {}),
3108
- ok: valgKlik?.landed !== false,
3949
+ ok: klikLandede(valgKlik),
3109
3950
  type: 'custom_dropdown',
3110
3951
  selected: oensket,
3111
- ...(valgKlik?.landed === false
3112
- ? { error: 'Klikket paa muligheden blev ikke taget imod af siden: ' + oensket }
3113
- : {}),
3952
+ // MAALT 12/9 af Astra: her stod fejlteksten paa ALT der ikke var bevist landet - ogsaa naar koden lige havde
3953
+ // regnet ud at den IKKE ved det. En skarp benaegtelse oven paa en uvished er mindre aerlig end ingen tekst.
3954
+ ...(uvisVurdering(valgKlik)
3955
+ || (!klikLandede(valgKlik)
3956
+ ? { error: 'Klikket paa muligheden blev ikke taget imod af siden: ' + oensket }
3957
+ : {})),
3114
3958
  };
3115
3959
  }
3116
3960
 
@@ -3166,6 +4010,8 @@ async function dispatch(port, method, params) {
3166
4010
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
3167
4011
  const urlPattern = params.url_pattern || '';
3168
4012
  const timeout = params.timeout || 15000;
4013
+ const netFrister = netvaerkFrister();
4014
+ const budgetSlut = Date.now() + netFrister.budgetMs;
3169
4015
 
3170
4016
  await debuggerAttach(tab.id);
3171
4017
  try {
@@ -3187,10 +4033,18 @@ async function dispatch(port, method, params) {
3187
4033
  if (!urlPattern || url.includes(urlPattern)) {
3188
4034
  chrome.debugger.onEvent.removeListener(listener);
3189
4035
  clearTimeout(timer);
3190
- // Try to get response body
3191
- cdpSend(tab.id, 'Network.getResponseBody', {
3192
- requestId: eventParams.requestId,
3193
- }).then(bodyResult => {
4036
+ // Try to get response body - kun inden for vaerktoejets budget (netvaerkBudgetMs). Naar den ikke frem, er
4037
+ // svaret body:null som i 1.29.0, i stedet for at serverens 30 s loeber ud.
4038
+ const bodyKald = cdpSend(tab.id, 'Network.getResponseBody', { requestId: eventParams.requestId });
4039
+ bodyKald.catch(() => {});
4040
+ let bodyUr;
4041
+ Promise.race([
4042
+ bodyKald,
4043
+ new Promise((ok) => {
4044
+ const bodyFrist = Math.min(netFrister.bodyMaxMs, Math.max(netFrister.bodyMinMs, budgetSlut - Date.now()));
4045
+ bodyUr = setTimeout(() => ok(null), bodyFrist);
4046
+ }),
4047
+ ]).finally(() => clearTimeout(bodyUr)).then(bodyResult => {
3194
4048
  resolve({
3195
4049
  ok: true,
3196
4050
  url,
@@ -3254,8 +4108,101 @@ async function dispatch(port, method, params) {
3254
4108
  }
3255
4109
 
3256
4110
  case 'get_cookies': {
3257
- const cookies = await chrome.cookies.getAll({ domain: params.domain });
3258
- return { cookies: cookies.map(c => ({ name: c.name, value: c.value, domain: c.domain, path: c.path })) };
4111
+ // MAALT 10/9, reproduceret i selen: uden `domain` blev filteret {} — altsaa INTET
4112
+ // filter, altsaa hver eneste cookie i profilen, inklusive httpOnly-sessionscookies
4113
+ // som sidens eget JS ikke maa se. Skemaet siger required: ['domain'], men serveren
4114
+ // videresender argumenter uvalideret, saa skemaet var en henstilling.
4115
+ if (!params.domain || typeof params.domain !== 'string' || !params.domain.trim()) {
4116
+ return {
4117
+ ok: false,
4118
+ error: 'domain-mangler',
4119
+ hint: 'Angiv `domain`. Uden det ville kaldet returnere HVER cookie i profilen — ' +
4120
+ 'ogsaa fra sider der intet har med opgaven at goere.',
4121
+ };
4122
+ }
4123
+ // MAALT 10/9, to runder. Foerste udgave gaettede domaeneslaegtskab ud fra fanernes
4124
+ // vaertsnavne ("a.example.com ligger under com"). Astra omgik det tre veje: en fane paa
4125
+ // https://com/ aabnede hele .com, en file://bank.example/-fane gav bankens cookies uden at
4126
+ // nogen side var aabnet, og et tomt vaertsnavn (about:blank) lod "bank.example." slippe
4127
+ // igennem. Et domaene kan man ikke raesonnere sig til uden public suffix-listen. Saa nu
4128
+ // spoerges Chrome i stedet: hvilke cookies ville du SENDE til de http(s)-sider sessionen har
4129
+ // aabne? Kun dem - og kun dem der passer paa det domaene der blev bedt om.
4130
+ const session = getSession(port);
4131
+ // Astra, tredje runde: en inkognito-fane har sit EGET cookie-lager. Uden storeId blev den
4132
+ // almindelige profils cookies laest for en inkognito-fane.
4133
+ let lagre = null;
4134
+ try { lagre = await chrome.cookies.getAllCookieStores(); } catch {}
4135
+ const lagerFor = (tabId) => (lagre || []).find((l) => (l.tabIds || []).includes(tabId))?.id;
4136
+ const sider = [];
4137
+ let ukendtLager = false;
4138
+ for (const id of session.tabIds) {
4139
+ const t = await chrome.tabs.get(id).catch(() => null);
4140
+ try {
4141
+ const u = new URL(t?.url || '');
4142
+ if ((u.protocol === 'https:' || u.protocol === 'http:') && u.hostname) {
4143
+ const storeId = lagerFor(id);
4144
+ // Astra (sign-off 11/9): fejlede getAllCookieStores for en inkognitofane, blev storeId udeladt, og Chrome
4145
+ // laeste den almindelige profils lager. Kan en inkognitofanes lager ikke findes, laeses intet for den.
4146
+ if (t.incognito && !storeId) { ukendtLager = true; continue; }
4147
+ // Fanens vaertsnavn er altid ASCII (punycode). Et afsluttende punktum er en ANDEN cookie-vaert i
4148
+ // Chromium (Astra, fjerde runde: x.example. fik cookies fra x.example) - saa det bevares.
4149
+ sider.push({ u, vaert: u.hostname.toLowerCase(), storeId });
4150
+ }
4151
+ } catch {}
4152
+ }
4153
+ if (!sider.length && ukendtLager) {
4154
+ return {
4155
+ ok: false, error: 'cookie-lager-ukendt',
4156
+ hint: 'Fanen er et inkognitovindue, og Chrome oplyste ikke dens cookie-lager. Intet blev laest - ellers ville ' +
4157
+ 'den almindelige profils cookies blive leveret i stedet.',
4158
+ };
4159
+ }
4160
+ const vaertsnavne = sider.map((x) => x.vaert);
4161
+ // Argumentet normaliseres som fanens adresse - "bücher.example" ER xn--bcher-kva.example.
4162
+ let d = params.domain.trim().toLowerCase().replace(/^\.+/, '');
4163
+ try { if (d) d = new URL('http://' + d + '/').hostname; } catch {}
4164
+ const slaegt = (a, b) => a === b || a.endsWith('.' + b) || b.endsWith('.' + a);
4165
+ if (!d || !vaertsnavne.some((h) => slaegt(h, d))) {
4166
+ return {
4167
+ ok: false, error: 'domaene-ikke-i-sessionen', domain: d, aabne: vaertsnavne,
4168
+ hint: 'Cookies kan kun laeses for http(s)-sider denne session har aabne. Naviger til ' +
4169
+ 'siden foerst - saa kan agenten ikke laese cookies fra noget den ikke arbejder med.',
4170
+ };
4171
+ }
4172
+ // Noeglen er et JSON-array, saa "a|b" i sti og navn ikke kan laegge to cookies sammen til én.
4173
+ const fundne = new Map();
4174
+ const med = (c, storeId) => {
4175
+ const cd = String(c.domain || '').toLowerCase().replace(/^\./, '');
4176
+ if (slaegt(cd, d)) fundne.set(JSON.stringify([storeId ?? '', c.domain, c.path, c.name]), c);
4177
+ };
4178
+ for (const side of sider) {
4179
+ const lager = side.storeId ? { storeId: side.storeId } : {};
4180
+ // Fable (sign-off 11/9): en session paa http:// fik en Secure-cookie fra overdomaenet. {url} udelader dem selv,
4181
+ // men opslagene paa {domain} kender ikke sidens protokol. Chrome sender aldrig en Secure-cookie til http.
4182
+ // Astra (efterproevning af c1496d4): Chromium regner localhost for sikker og sender Secure-cookies dertil over http.
4183
+ const sikkerVaert = side.u.protocol === 'https:' || side.vaert === 'localhost' || side.vaert.endsWith('.localhost') ||
4184
+ /^127(\.\d{1,3}){3}$/.test(side.vaert) || side.vaert === '[::1]';
4185
+ const sendesOverProtokollen = (c) => !c.secure || sikkerVaert;
4186
+ // De cookies Chrome ville SENDE til siden - inklusive overdomaenets ...
4187
+ for (const c of await chrome.cookies.getAll({ url: side.u.href, ...lager })) med(c, side.storeId);
4188
+ // ... plus sidens EGNE cookies paa alle stier (Path=/api kom ikke med ovenfor). {domain} giver
4189
+ // ogsaa underdomaener og, for et public suffix, hele suffixet - saa kun cookies hvis domaene ER
4190
+ // vaertsnavnet.
4191
+ for (const c of await chrome.cookies.getAll({ domain: side.vaert, ...lager })) {
4192
+ if (String(c.domain || '').toLowerCase().replace(/^\./, '') === side.vaert && sendesOverProtokollen(c)) med(c, side.storeId);
4193
+ }
4194
+ // MAALT 11/9 af Astra (R5 F7): overdomaenets cookie paa en anden sti (Domain=.example.com; Path=/api) kom
4195
+ // med i 1.29.0, men ikke her: {url} giver kun sidens egen sti, og {domain: vaert} holdt kun cookies hvis
4196
+ // domaene ER vaertsnavnet. Chrome spoerges nu ogsaa om det domaene der blev bedt om, og kun cookies som
4197
+ // DENNE vaert ville faa tilsendt paa en eller anden sti beholdes. En host-only-cookie sendes kun til sin
4198
+ // egen vaert, saa den skal passe praecist.
4199
+ for (const c of await chrome.cookies.getAll({ domain: d, ...lager })) {
4200
+ const cd = String(c.domain || '').toLowerCase().replace(/^\./, '');
4201
+ const sendesTilVaerten = c.hostOnly ? side.vaert === cd : (side.vaert === cd || side.vaert.endsWith('.' + cd));
4202
+ if (sendesTilVaerten && sendesOverProtokollen(c)) med(c, side.storeId);
4203
+ }
4204
+ }
4205
+ return { cookies: [...fundne.values()].map(c => ({ name: c.name, value: c.value, domain: c.domain, path: c.path })) };
3259
4206
  }
3260
4207
 
3261
4208
  case 'get_local_storage': {
@@ -3273,12 +4220,64 @@ async function dispatch(port, method, params) {
3273
4220
  }
3274
4221
 
3275
4222
  case 'set_cookies': {
4223
+ // MAALT 11/9 af Opus og Fable (e2e-review): laesningen er begraenset til sessionens egne sider, men skrivningen satte
4224
+ // cookies paa ETHVERT domaene - ogsaa sider sessionen aldrig har aabnet (og butikkens begrundelse lovede det modsatte).
4225
+ // Samme regel begge veje: kun de http(s)-vaerter sessionen har aabne.
4226
+ // MAALT 11/9 (e2e runde 2): slaegtskabet blev laest BEGGE veje, saa en cookie til et uaabnet UNDERdomaene slap
4227
+ // igennem (Astra), adressen kom fra kalderen i stedet for sessionens side (Opus), og en inkognitofane skrev i den
4228
+ // almindelige profils lager (Opus). Reglen er nu laesningens: cookiens domaene skal vaere vaerten selv eller et
4229
+ // overdomaene vaerten faar cookies fra, adressen bygges af sidens egen oprindelse (saa Chrome haandhaever bl.a.
4230
+ // public suffix), og lageret foelger fanen.
4231
+ const saetSession = getSession(port);
4232
+ let saetUkendtLager = false;
4233
+ let saetLagre = null;
4234
+ try { saetLagre = await chrome.cookies.getAllCookieStores(); } catch {}
4235
+ const saetSider = [];
4236
+ for (const id of saetSession.tabIds) {
4237
+ const t = await chrome.tabs.get(id).catch(() => null);
4238
+ try {
4239
+ const u = new URL(t?.url || '');
4240
+ if ((u.protocol !== 'https:' && u.protocol !== 'http:') || !u.hostname) continue;
4241
+ const storeId = (saetLagre || []).find((l) => (l.tabIds || []).includes(id))?.id;
4242
+ if (t.incognito && !storeId) { saetUkendtLager = true; continue; }
4243
+ saetSider.push({ u, vaert: u.hostname.toLowerCase(), storeId });
4244
+ } catch {}
4245
+ }
4246
+ if (!saetSider.length && saetUkendtLager) {
4247
+ return {
4248
+ ok: false, error: 'cookie-lager-ukendt',
4249
+ hint: 'Fanen er et inkognitovindue, og Chrome oplyste ikke dens cookie-lager. Intet blev skrevet - ellers ville ' +
4250
+ 'cookien lande i den almindelige profil i stedet.',
4251
+ };
4252
+ }
4253
+ // Sidens vaert faar cookies fra sig selv og fra sine overdomaener - ikke fra et underdomaene den ikke har aabnet.
4254
+ // MAALT 12/9 af Astra: `find` tog den FOERSTE fane der endte paa domaenet, saa en aaben a.example.com vandt over en
4255
+ // lige saa aaben example.com - og adressen blev derefter bygget af den forkerte vaert. Den noejagtige vaert vinder nu.
4256
+ // Slaegtskabs-stien (sidens vaert faar cookies fra sit overdomaene) kraever at kalderen SELV har sagt `domain`:
4257
+ // uden `domain` er cookien host-only, og saa kan oensket ikke opfyldes fra et underdomaene.
4258
+ const saetSideFor = (cd, eksplicitDomaene) =>
4259
+ saetSider.find((s) => s.vaert === cd)
4260
+ || (eksplicitDomaene ? saetSider.find((s) => s.vaert.endsWith('.' + cd)) : undefined);
3276
4261
  const results = [];
3277
4262
  const cookieList = Array.isArray(params.cookies) ? params.cookies : [params];
3278
4263
  for (const c of cookieList) {
4264
+ let cd = String(c.domain || '').trim().toLowerCase().replace(/^\.+/, '');
4265
+ if (!cd && c.url) { try { cd = new URL(c.url).hostname.toLowerCase(); } catch {} }
4266
+ try { if (cd) cd = new URL('http://' + cd + '/').hostname; } catch {}
4267
+ const side = cd ? saetSideFor(cd, Boolean(String(c.domain || '').trim())) : null;
4268
+ if (!side) {
4269
+ results.push({
4270
+ ok: false, name: c.name, error: 'domaene-ikke-i-sessionen', domain: cd || null,
4271
+ aabne: saetSider.map((s) => s.vaert),
4272
+ hint: 'Cookies kan kun saettes for http(s)-sider denne session har aabne, og kun for sidens eget domaene ' +
4273
+ 'eller - hvis du selv angiver domain - et overdomaene den faar cookies fra. Naviger til siden foerst.',
4274
+ });
4275
+ continue;
4276
+ }
3279
4277
  try {
3280
4278
  const cookie = await chrome.cookies.set({
3281
- url: c.url || `https://${c.domain}`,
4279
+ // Adressen bygges af sessionens egen side - saa Chrome selv haandhaever sine domaeneregler (public suffix osv.).
4280
+ url: side.u.origin + (typeof c.path === 'string' && c.path.startsWith('/') ? c.path : '/'),
3282
4281
  name: c.name,
3283
4282
  value: c.value,
3284
4283
  domain: c.domain,
@@ -3286,6 +4285,7 @@ async function dispatch(port, method, params) {
3286
4285
  secure: c.secure !== false,
3287
4286
  httpOnly: c.httpOnly || false,
3288
4287
  sameSite: c.sameSite || 'lax',
4288
+ ...(side.storeId ? { storeId: side.storeId } : {}),
3289
4289
  });
3290
4290
  results.push({ ok: true, name: c.name });
3291
4291
  } catch (e) {
@@ -3370,7 +4370,7 @@ async function dispatch(port, method, params) {
3370
4370
  chrome.notifications.create(notifId, {
3371
4371
  type: 'basic',
3372
4372
  iconUrl: 'icons/icon-128.png',
3373
- title: `${session.label} Action Required`,
4373
+ title: `${session.label} - Action Required`,
3374
4374
  message: params.message,
3375
4375
  requireInteraction: true,
3376
4376
  silent: false,
@@ -3431,7 +4431,7 @@ async function dispatch(port, method, params) {
3431
4431
 
3432
4432
  const h = document.createElement('div');
3433
4433
  h.style.cssText = 'font-size:14px;font-weight:600;color:#3b82f6;margin-bottom:4px';
3434
- h.textContent = title || 'Agent360 Action Required';
4434
+ h.textContent = title || 'Agent360 - Action Required';
3435
4435
  card.appendChild(h);
3436
4436
  const badge = document.createElement('div');
3437
4437
  badge.style.cssText = 'font-size:10px;color:#94a3b8;margin-bottom:12px';
@@ -3485,7 +4485,7 @@ async function dispatch(port, method, params) {
3485
4485
  });
3486
4486
  },
3487
4487
  // 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
4488
+ // med standarden "Agent360 - Action Required", men udelades den, er vaerdien
3489
4489
  // undefined — og chrome.scripting.executeScript afviser hele kaldet med
3490
4490
  // "Error at property 'args': Error at index 1: Value is unserializable".
3491
4491
  // Altsaa styrtede human-in-the-loop-vaerktoejet hver gang en agent fulgte sit
@@ -3496,7 +4496,7 @@ async function dispatch(port, method, params) {
3496
4496
  // anvendes der hvor den er lovet.
3497
4497
  args: [
3498
4498
  String(params.message ?? ''),
3499
- String(params.title ?? 'Agent360 Action Required'),
4499
+ String(params.title ?? 'Agent360 - Action Required'),
3500
4500
  Array.isArray(fields) ? fields : [],
3501
4501
  Boolean(hasFields),
3502
4502
  Number(timeout) || 120000,
@@ -3563,7 +4563,23 @@ async function dispatch(port, method, params) {
3563
4563
  if (!lastCreatedTabId) return { error: 'No new tab detected' };
3564
4564
  try {
3565
4565
  const tab = await chrome.tabs.get(lastCreatedTabId);
3566
- // Claim the new tab for this session
4566
+ // Kun faner der er aabnet FRA en af sessionens egne faner. Uden det her overtog
4567
+ // agenten enhver fane brugeren selv havde aabnet — se kommentaren ved onCreated.
4568
+ const session = getSession(port);
4569
+ const opener = tab.openerTabId ?? openerForFane.get(tab.id) ?? null;
4570
+ // Aabneren i sessionen NU, eller sessionen der ejede aabneren da fanen blev oprettet
4571
+ // (aabneren kan vaere lukket siden - se ejerForFane ved onCreated).
4572
+ const voresNu = opener != null && session.tabIds.has(opener);
4573
+ const voresDaDenBlevAabnet = opener != null && ejerForFane.get(tab.id) === port;
4574
+ if (!voresNu && !voresDaDenBlevAabnet) {
4575
+ return {
4576
+ error: 'not-ours',
4577
+ hint: 'Den seneste nye fane blev ikke aabnet fra en af dine egne faner, saa den ' +
4578
+ 'tilhoerer brugeren. Brug browser_navigate(new_tab: true) hvis du selv skal ' +
4579
+ 'have en ny fane.',
4580
+ tab_id: tab.id,
4581
+ };
4582
+ }
3567
4583
  await addTabToSession(port, tab.id);
3568
4584
  return { id: tab.id, url: tab.url, title: tab.title };
3569
4585
  } catch {
@@ -3658,7 +4674,7 @@ async function dispatch(port, method, params) {
3658
4674
  const { result: nodeResult } = await cdpSend(tab.id, 'Runtime.evaluate', {
3659
4675
  expression: `(() => {
3660
4676
  const el = document.querySelector(${JSON.stringify(selector)});
3661
- if (!el) return JSON.stringify({ found: false, error: 'File input not found: ${selector}' });
4677
+ if (!el) return JSON.stringify({ found: false, error: 'File input not found: ' + ${JSON.stringify(selector)} });
3662
4678
  return JSON.stringify({ found: true, tag: el.tagName, type: el.type, accept: el.accept, multiple: el.multiple });
3663
4679
  })()`,
3664
4680
  returnByValue: true,
@@ -3932,6 +4948,8 @@ chrome.runtime.onStartup.addListener(() => ensureOffscreen().catch(console.error
3932
4948
  // MAALT 22/8: uden tvangen slog en genindlaesning aldrig igennem til broen, og
3933
4949
  // udvikling krævede en fuld genstart af Chrome hver gang.
3934
4950
  chrome.runtime.onInstalled.addListener(async () => {
4951
+ // Foerst: aeldre udgaver gemte parametre (adgangskoder, cookie-vaerdier) i historikken.
4952
+ await rensHandlingslog();
3935
4953
  try {
3936
4954
  if (await chrome.offscreen.hasDocument()) await chrome.offscreen.closeDocument();
3937
4955
  } catch (e) {