5569 lines
284 KiB
JavaScript
5569 lines
284 KiB
JavaScript
/**
|
||
* Agent360 Browser MCP — Background Service Worker
|
||
*
|
||
* Handles Chrome API calls relayed from the offscreen document.
|
||
* Each MCP session (port) gets its own Chrome Tab Group with color coding.
|
||
* Tabs are isolated per session — no cross-session interference.
|
||
*/
|
||
|
||
// ── Session Tab Management ─────────────────────────────────────────────────
|
||
|
||
const SESSION_COLORS = ['blue', 'green', 'yellow', 'red', 'pink', 'purple', 'cyan', 'orange'];
|
||
// Select-all modifier is platform-dependent: Cmd (meta=4) on macOS, Ctrl (2) elsewhere.
|
||
// Get this wrong and the field isn't selected — Backspace no-ops and new text concatenates onto the old.
|
||
const SELECT_ALL_MODS = /Mac/i.test(navigator.userAgent) ? 4 : 2;
|
||
const sessions = new Map(); // port → { tabIds: Set, groupId: number|null, color: string, label: string }
|
||
// FIX-2: promise-cache latch (not a boolean). The old `if(sessionsLoaded) return`
|
||
// flipped the flag BEFORE awaiting storage, so a second concurrent caller on a freshly
|
||
// woken service worker proceeded against an EMPTY sessions Map. Caching the promise makes
|
||
// every concurrent caller await the SAME populated completion. Resets to null on SW
|
||
// eviction (module re-init) and on error, so the next wake retries.
|
||
let restorePromise = null;
|
||
|
||
// Restore sessions from storage (service workers lose in-memory state on suspend)
|
||
function restoreSessions() {
|
||
if (restorePromise) return restorePromise;
|
||
restorePromise = (async () => {
|
||
const { sessions: saved } = await chrome.storage.local.get({ sessions: {} });
|
||
for (const [port, data] of Object.entries(saved)) {
|
||
// Verify tabs still exist
|
||
const validTabIds = new Set();
|
||
for (const tabId of (data.tabIds || [])) {
|
||
try {
|
||
await chrome.tabs.get(tabId);
|
||
validTabIds.add(tabId);
|
||
} catch {} // tab no longer exists
|
||
}
|
||
if (validTabIds.size > 0) {
|
||
const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
|
||
// Samme kollisionsfejl som i getSession, og derfor samme rettelse: `size + 1`
|
||
// genbruger et nummer der allerede er i brug. Her betyder det at to gendannede
|
||
// sessioner kan komme op med samme navn efter en genstart af service-workeren.
|
||
const brugte = new Set([...sessions.values()].map((x) => x.nummer).filter((n) => typeof n === 'number'));
|
||
let nummer = typeof data.nummer === 'number' ? data.nummer : 1;
|
||
while (brugte.has(nummer)) nummer++;
|
||
sessions.set(Number(port), {
|
||
tabIds: validTabIds,
|
||
activeTabId,
|
||
groupId: data.groupId || null,
|
||
nummer,
|
||
// MAALT 22/8: her stod `data.color || …` og `data.label || …`. Bumpede
|
||
// kollisionsloekken nummeret, fulgte navn og farve IKKE med — de blev
|
||
// gendannet ordret fra lageret. To sessioner kunne saa have hvert sit
|
||
// nummer og stadig begge hedde "Claude 1" i samme farve. Og navnet er
|
||
// praecis dét brugeren ser paa fanegruppen.
|
||
color: SESSION_COLORS[(nummer - 1) % SESSION_COLORS.length],
|
||
label: `Claude ${nummer}`,
|
||
pid: typeof data.pid === 'number' ? data.pid : null,
|
||
});
|
||
}
|
||
}
|
||
})().catch(err => { restorePromise = null; throw err; });
|
||
return restorePromise;
|
||
}
|
||
|
||
function getSession(port, pid) {
|
||
if (!sessions.has(port)) {
|
||
// ── Hvorfor det laveste LEDIGE nummer, og ikke sessions.size + 1 (MAALT 22/8) ──
|
||
// Med `size + 1` genbruges et nummer der allerede er i brug, saa snart en chat
|
||
// lukker: tre chats hedder 1, 2, 3 · chat 1 lukker · size er nu 2 · naeste chat
|
||
// faar "Claude 3" — som chat 3 stadig hedder. To chats deler navn OG farve, og
|
||
// brugeren kan ikke se hvilken fanegruppe der hoerer til hvad.
|
||
//
|
||
// Det er en TREDJE mekanisme bag "alt hedder Claude 1", uafhaengig af de to andre
|
||
// (to udvidelser om samme socket, og adoption uden live-port-gate). Den her
|
||
// rammer ogsaa naar alt andet er rigtigt — man skal bare lukke en chat.
|
||
//
|
||
// Laveste ledige nummer genbruger frigivne pladser uden at kollidere, saa numrene
|
||
// bliver ved med at vaere smaa og laesbare. Farven foelger nummeret, saa to
|
||
// samtidige sessioner heller ikke kan faa samme farve.
|
||
const brugte = new Set([...sessions.values()].map((s) => s.nummer).filter((n) => typeof n === 'number'));
|
||
let nummer = 1;
|
||
// Havde denne chat en plads foer den slap sin port, og er den stadig ledig, saa faar
|
||
// den sin egen tilbage — se pladsPrPid. Ellers laveste ledige, som foer.
|
||
const husket = typeof pid === 'number' ? pladsPrPid.get(pid) : undefined;
|
||
if (typeof husket === 'number' && !brugte.has(husket)) {
|
||
nummer = husket;
|
||
} else {
|
||
while (brugte.has(nummer)) nummer++;
|
||
}
|
||
husketPlads(pid, nummer);
|
||
|
||
sessions.set(port, {
|
||
tabIds: new Set(),
|
||
activeTabId: null,
|
||
groupId: null,
|
||
nummer,
|
||
color: SESSION_COLORS[(nummer - 1) % SESSION_COLORS.length],
|
||
label: `Claude ${nummer}`,
|
||
pid: typeof pid === 'number' ? pid : null,
|
||
});
|
||
}
|
||
const s = sessions.get(port);
|
||
// Foerste kald fra en genstartet server kan baere pid'en foer sessionen har den.
|
||
if (s.pid == null && typeof pid === 'number') { s.pid = pid; husketPlads(pid, s.nummer); }
|
||
return s;
|
||
}
|
||
|
||
// Adopt tabs from a session whose MCP connection is gone (FIX-18: reconnect orphaning).
|
||
//
|
||
// Sessions are keyed on the MCP port. A clean shutdown sends session_disconnect and the
|
||
// tabs are closed. But an UNCLEAN drop (server restart, network blip, laptop sleep) leaves
|
||
// the session in storage while the client reconnects on a NEW port — so getSession() hands
|
||
// back an empty session. Observed damage: navigate() returns tab X while click() fails on
|
||
// tab Y, list_tabs() comes back empty, a fresh about:blank spawns per call, and the session
|
||
// label walks Claude 1 → 2 → 3 → 4. Every navigate→click pair breaks.
|
||
//
|
||
// Fix: before serving an unknown port, hand over the tabs of the largest session whose port
|
||
// is no longer in mcpPorts (the live-connection list the offscreen bridge keeps in storage).
|
||
// Keeps label/color so the user sees continuity instead of a renumbered session.
|
||
async function adoptOrphanedSession(port, pid) {
|
||
// Only ever fires for a session that OWNS NOTHING — a session with tabs is left alone.
|
||
if (sessions.has(port) && sessions.get(port).tabIds.size) return null;
|
||
|
||
// ── Hvorfor pid'en er afgoerende (regression fundet 16/8) ────────────────────
|
||
// Den oprindelige version adopterede den STOERSTE session uanset hvem den tilhoerte,
|
||
// med den begrundelse at Claude Code spreder kald over flere forbindelser. Men en
|
||
// helt NY chat ejer ogsaa ingenting — saa hver ny chat stjal den aktive chats faner
|
||
// OG dens identitet (linje: sessions.delete(best.port)). Donorens port forsvandt fra
|
||
// kortet, saa dens naeste kald adopterede tilbage. To chats byttede den samme ene
|
||
// session frem og tilbage: alt hed "Claude 1", og kun én ting kunne koere ad gangen.
|
||
//
|
||
// Det aegte behov er smallere: naar EN chats MCP-server genstarter, faar den en ny
|
||
// port og skal genfinde sine egne faner. Den situation kan skelnes praecist, fordi
|
||
// begge porte hoerer til den samme Claude Code-proces. Derfor: adoptér kun fra en
|
||
// session med samme pid. Mangler pid'en (gammel server mod ny udvidelse), adopteres
|
||
// slet ikke — hellere en frisk session end en stjaalet.
|
||
if (typeof pid !== 'number') return null;
|
||
|
||
// ── Anden halvdel af gaten (MAALT 21/8) ─────────────────────────────────────
|
||
// Pid'en alene raekker ikke. Foraelder-processen er IKKE en unik identitet: starter
|
||
// en klient flere MCP-servere fra den samme proces, har de alle samme pid — og saa
|
||
// adopterede de hinandens faner paa stribe. Maalt med fire samtidige sessioner:
|
||
// alle fik navnet "Claude 3", de tre aeldste mistede deres fane, og en session
|
||
// kunne skifte til en andens. Altsaa "alt hedder Claude 1", i sin rene form, med
|
||
// kun én udvidelse indlaest.
|
||
//
|
||
// Det manglende tjek staar allerede beskrevet oeverst i denne funktion: adoptér kun
|
||
// fra en session hvis port IKKE laengere er forbundet. Er donorens port stadig i
|
||
// live, er det en anden chat der arbejder lige nu — ikke en genstartet server.
|
||
// Listen vedligeholdes af broen (ws_status → mcpPorts) ved hver til- og frakobling.
|
||
const { mcpPorts = [] } = await chrome.storage.local.get({ mcpPorts: [] });
|
||
const levendePorte = new Set(mcpPorts.map(Number));
|
||
|
||
let best = null;
|
||
for (const [p, session] of sessions) {
|
||
if (p === port || !session.tabIds.size) continue;
|
||
if (session.pid !== pid) continue; // en anden chat — lad den vaere
|
||
if (levendePorte.has(Number(p))) continue; // donoren arbejder stadig — hænderne væk
|
||
if (!best || session.tabIds.size > best.session.tabIds.size) best = { port: p, session };
|
||
}
|
||
if (!best) return null;
|
||
|
||
// Verify at least one tab survives — an orphan whose tabs the user already closed is
|
||
// worthless, and adopting it would mask a genuinely fresh start.
|
||
const alive = new Set();
|
||
for (const tabId of best.session.tabIds) {
|
||
try { await chrome.tabs.get(tabId); alive.add(tabId); } catch {}
|
||
}
|
||
if (!alive.size) {
|
||
sessions.delete(best.port);
|
||
persistSessions();
|
||
return null;
|
||
}
|
||
|
||
best.session.tabIds = alive;
|
||
if (!alive.has(best.session.activeTabId)) best.session.activeTabId = null;
|
||
sessions.delete(best.port);
|
||
sessions.set(port, best.session);
|
||
persistSessions();
|
||
return best.session;
|
||
}
|
||
|
||
// LRU eviction cap: hver session må højst have N åbne tabs samtidigt.
|
||
// Når en ny tab tilføjes ud over cap'en, lukkes den ÆLDSTE tab i sessionen
|
||
// (insertion-order via Set) — bortset fra session.activeTabId (current tab).
|
||
// Begrundelse: Claude Code-flows kan åbne 20+ navigate(new_tab=true) per session
|
||
// over en længere conversation. Uden eviction akkumulerer disse i Chrome som
|
||
// orphan-tabs der spiser RAM + giver "extension localhost 19+" tab-noise.
|
||
//
|
||
// Hævet 10 → 20 (21/8). Ti var for lavt til reelle flows: en jagt der åbner en
|
||
// fane pr. udbyder ramte loftet midtvejs, og evictionen lukkede de faner arbejdet
|
||
// stadig byggede på — tavst, for eviction rapporterer ikke noget. Tyve matcher
|
||
// portspændet (9876-9895), så en session kan holde lige så mange faner som der
|
||
// kan køre samtidige sessioner.
|
||
const MAX_TABS_PER_SESSION = 20;
|
||
|
||
async function evictOldestTabs(session, justAddedTabId) {
|
||
// Drop dead tab-ids først (user manually closed dem)
|
||
for (const id of [...session.tabIds]) {
|
||
try {
|
||
await chrome.tabs.get(id);
|
||
} catch {
|
||
session.tabIds.delete(id);
|
||
}
|
||
}
|
||
// Evict oldest indtil ≤ cap. Skip activeTabId og just-added tab.
|
||
const ordered = [...session.tabIds];
|
||
for (const oldId of ordered) {
|
||
if (session.tabIds.size <= MAX_TABS_PER_SESSION) break;
|
||
if (oldId === session.activeTabId) continue;
|
||
if (oldId === justAddedTabId) continue;
|
||
try {
|
||
await chrome.tabs.remove(oldId);
|
||
} catch {} // tab may already be closed
|
||
session.tabIds.delete(oldId);
|
||
}
|
||
}
|
||
|
||
async function addTabToSession(port, tabId) {
|
||
const session = getSession(port);
|
||
session.tabIds.add(tabId);
|
||
// Sessionen lever igen — aflys en eventuel port-frigivelse (se tabs.onRemoved).
|
||
chrome.alarms.clear(`frigiv-${port}`).catch(() => {});
|
||
|
||
// LRU eviction: når sessionen overstiger cap, luk de ældste tabs.
|
||
if (session.tabIds.size > MAX_TABS_PER_SESSION) {
|
||
await evictOldestTabs(session, tabId);
|
||
}
|
||
|
||
try {
|
||
if (session.groupId !== null) {
|
||
try {
|
||
await chrome.tabs.group({ tabIds: [tabId], groupId: session.groupId });
|
||
} catch {
|
||
// Group no longer valid — will create new one below
|
||
session.groupId = null;
|
||
}
|
||
}
|
||
|
||
if (session.groupId === null) {
|
||
const groupId = await chrome.tabs.group({ tabIds: [...session.tabIds] });
|
||
session.groupId = groupId;
|
||
await chrome.tabGroups.update(groupId, {
|
||
title: session.label,
|
||
color: session.color,
|
||
collapsed: false,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('[MCP] Tab group error:', e.message);
|
||
}
|
||
|
||
persistSessions();
|
||
}
|
||
|
||
async function releaseSession(port) {
|
||
const session = sessions.get(port);
|
||
if (!session) return;
|
||
|
||
// ── Bad vi selv om frigivelsen? (FUNDET AF REVIEW 7/9) ────────────────────
|
||
//
|
||
// Vejen fra "sessionen er tom" til denne funktion gaar over mindst tre hop:
|
||
// sendMessage -> offscreen sender terminate og lukker WS -> ws.onclose ->
|
||
// session_disconnect. Aabner agenten en fane i de ~50 ms undervejs, lukkede
|
||
// oprydningen herunder DEN fane — paa grundlag af en beslutning der blev truffet
|
||
// foer fanen fandtes. Det er praecis "agenten holder pause og genoptager"-
|
||
// scenariet frigivelsen er bygget til at understoette.
|
||
//
|
||
// En UVENTET afbrydelse (chatten er vaek) skal stadig lukke fanerne — derfor
|
||
// skelnes der, i stedet for bare at tjekke om sessionen er tom.
|
||
// #12: sessionen forsvinder her — dens frist skal med, uanset hvilken vej vi gaar ud.
|
||
chrome.alarms.clear(`frigiv-${port}`).catch(() => {});
|
||
|
||
if (frivilligtFrigivet.delete(port)) {
|
||
if (session.tabIds.size) {
|
||
// Sessionen arbejder igen. Behold den, saa naeste binding kan adoptere den
|
||
// (adoptOrphanedSession finder donorer med samme pid OG faner).
|
||
persistSessions();
|
||
return;
|
||
}
|
||
sessions.delete(port);
|
||
persistSessions();
|
||
return;
|
||
}
|
||
|
||
// Detach debugger + close all session tabs
|
||
//
|
||
// #13: fejlene blev slugt her, og `sessions.delete(port)` koerte alligevel. En fane der
|
||
// ikke KUNNE lukkes blev dermed foraeldreloes: stadig aaben, stadig i en farvet gruppe,
|
||
// men uden for enhver session — usynlig for list_tabs og aldrig ryddet op. Praeeksisterende,
|
||
// men v1.29 slipper porte langt oftere, saa stien koeres langt hyppigere end foer.
|
||
const stadigAabne = new Set();
|
||
for (const tabId of [...session.tabIds]) {
|
||
debuggerForceDetach(tabId);
|
||
try {
|
||
await chrome.tabs.remove(tabId);
|
||
session.tabIds.delete(tabId);
|
||
} catch {
|
||
// Fanen kan vaere lukket i forvejen — saa er den vaek, og det er fint. Findes den
|
||
// stadig, beholder vi den: bedre en session der lever lidt for laenge end en fane
|
||
// ingen ejer.
|
||
try {
|
||
await chrome.tabs.get(tabId);
|
||
stadigAabne.add(tabId);
|
||
} catch {
|
||
session.tabIds.delete(tabId);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (stadigAabne.size) {
|
||
console.warn('[BG] kunne ikke lukke', [...stadigAabne], '— sessionen beholdes saa fanerne ikke strander');
|
||
session.tabIds = stadigAabne;
|
||
persistSessions();
|
||
return;
|
||
}
|
||
|
||
sessions.delete(port);
|
||
persistSessions();
|
||
}
|
||
|
||
function persistSessions() {
|
||
const data = {};
|
||
for (const [port, session] of sessions) {
|
||
data[port] = {
|
||
tabIds: [...session.tabIds],
|
||
activeTabId: session.activeTabId,
|
||
groupId: session.groupId,
|
||
color: session.color,
|
||
label: session.label,
|
||
nummer: session.nummer ?? null, // uden denne mister en gendannet session sin plads og kan kollidere
|
||
pid: session.pid ?? null, // uden denne adopterer en genstartet service worker paa tvaers af chats igen
|
||
};
|
||
}
|
||
chrome.storage.local.set({ sessions: data });
|
||
}
|
||
|
||
// Get the active tab for this session (last navigated), or create one.
|
||
// activate=false (default): runs in background — no focus stealing.
|
||
// activate=true: only for commands that NEED visible tab (screenshot, ask_user, navigate, execute_script).
|
||
async function getSessionTab(port, activate = false) {
|
||
const session = getSession(port);
|
||
let target = null;
|
||
// Remember our OWN about:blank placeholder so we reuse it instead of spawning another
|
||
// on every read-only call before the first navigate (FIX-4: about:blank proliferation).
|
||
let blankFallback = null;
|
||
const consider = (tab) => {
|
||
if (!tab) return false;
|
||
if (tab.url.startsWith('chrome://')) return false;
|
||
if (tab.url.startsWith('about:')) { if (!blankFallback) blankFallback = tab; return false; }
|
||
return true;
|
||
};
|
||
|
||
// Prefer the active (last navigated) tab
|
||
if (session.activeTabId) {
|
||
try {
|
||
const tab = await chrome.tabs.get(session.activeTabId);
|
||
if (consider(tab)) target = tab;
|
||
} catch {
|
||
const dead = session.activeTabId; // FIX-17: capture id BEFORE nulling (was deleting null)
|
||
session.activeTabId = null;
|
||
session.tabIds.delete(dead);
|
||
}
|
||
}
|
||
|
||
// Fallback: any usable session tab
|
||
if (!target) {
|
||
for (const tabId of session.tabIds) {
|
||
try {
|
||
const tab = await chrome.tabs.get(tabId);
|
||
if (consider(tab)) { session.activeTabId = tabId; target = tab; break; }
|
||
} catch {
|
||
session.tabIds.delete(tabId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Reuse our own blank placeholder rather than spawning yet another one (FIX-4).
|
||
if (!target && blankFallback) {
|
||
target = blankFallback;
|
||
session.activeTabId = target.id;
|
||
persistSessions();
|
||
}
|
||
|
||
// No usable tab at all — create ONE placeholder and pin it as the active tab so the
|
||
// NEXT call reuses it (FIX-4) instead of creating a fresh about:blank every time.
|
||
if (!target) {
|
||
target = await chrome.tabs.create({ url: 'about:blank', active: false });
|
||
await addTabToSession(port, target.id);
|
||
session.activeTabId = target.id;
|
||
persistSessions();
|
||
// fall through to the activate branch (SC-3: previously returned early, skipping it)
|
||
}
|
||
|
||
// Activate the tab WITHOUT stealing the user\'s focus (FIX-1). This is a BACKGROUND tool:
|
||
// screenshot/press_key run constantly, so we must NOT chrome.windows.update({focused:true})
|
||
// here — that yanked Chrome to the foreground on every action. We only (a) un-minimize a
|
||
// minimized window (needed so it can composite) and (b) make the tab active within its
|
||
// window. The truly-occluded (covered) case is handled as a bounded last-resort
|
||
// raise-and-restore inside the screenshot handler only.
|
||
if (activate) {
|
||
try {
|
||
if (target.windowId != null) {
|
||
const win = await chrome.windows.get(target.windowId).catch(() => null);
|
||
if (win && win.state === 'minimized') {
|
||
await chrome.windows.update(target.windowId, { state: 'normal' }); // no focused:true
|
||
}
|
||
}
|
||
if (!target.active) await chrome.tabs.update(target.id, { active: true });
|
||
await new Promise(r => setTimeout(r, 150));
|
||
target = await chrome.tabs.get(target.id);
|
||
} catch { /* best-effort; capture path surfaces the real error */ }
|
||
}
|
||
|
||
return target;
|
||
}
|
||
|
||
// ── Chrome Debugger API Helpers (CSP-bypass for Google, Stripe, Slack) ─────
|
||
|
||
// Track which tabs have debugger attached to avoid repeated attach/detach
|
||
const debuggerAttached = new Set();
|
||
|
||
// Verify Chrome\'s actual debugger-truth before trusting local cache.
|
||
// Fixes "ghost-attached" state where Set says attached but Chrome side is gone
|
||
// (happens on SW lifecycle events, user-canceled banners, anti-automation evictions).
|
||
async function verifyAttachedWithChrome(tabId) {
|
||
try {
|
||
const targets = await chrome.debugger.getTargets();
|
||
const t = targets.find(x => x.tabId === tabId);
|
||
return !!t?.attached;
|
||
} catch {
|
||
return false; // assume not-attached on API error
|
||
}
|
||
}
|
||
|
||
async function debuggerAttach(tabId) {
|
||
// First check local cache — fast path
|
||
if (debuggerAttached.has(tabId)) {
|
||
// Verify with Chrome before trusting cache (cheap, ~1ms)
|
||
if (await verifyAttachedWithChrome(tabId)) return;
|
||
// Cache was stale — Chrome doesn't actually have us attached
|
||
debuggerAttached.delete(tabId);
|
||
}
|
||
|
||
// Up to 3 attempts. A "ghost attach" (attach resolves but getTargets shows the tab
|
||
// NOT attached) is usually TRANSIENT: the page is mid-navigation/reload — e.g. the
|
||
// Metro dev-server rebuilding localhost:8081 auto-detaches the debugger. Retrying
|
||
// after a short delay lets the reload settle. Only a ghost that survives all retries
|
||
// is a real user-canceled banner. (Previously we threw on the first ghost, which made
|
||
// dev-server URLs unusable during their initial bundle.)
|
||
let lastMsg = '';
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
await chrome.debugger.attach({ tabId }, '1.3');
|
||
if (await verifyAttachedWithChrome(tabId)) {
|
||
debuggerAttached.add(tabId);
|
||
return;
|
||
}
|
||
// Ghost — detach cleanly so the next attempt starts fresh, then retry.
|
||
lastMsg = 'attach resolved but Chrome shows tab not attached (ghost — page likely mid-reload)';
|
||
try { await chrome.debugger.detach({ tabId }); } catch {}
|
||
} catch (e) {
|
||
// MAALT 31/8 af den nye udvidelses-test: her stod `includes('Already attached')`
|
||
// med stort A. Chromes faktiske besked er "Another debugger is already attached
|
||
// to the tab with id: N" — med lille. Tjekket ramte ALDRIG. Resultat: naar en
|
||
// anden debugger havde fanen (DevTools aabent, en anden udvidelse), blev det
|
||
// behandlet som en fejl, proevet tre gange, og kastet — i stedet for bare at
|
||
// bruge den session der allerede fandtes.
|
||
if (/already attached/i.test(e.message || '')) {
|
||
// Chrome side has session — sync local cache
|
||
debuggerAttached.add(tabId);
|
||
return;
|
||
}
|
||
// "Cannot attach"/"canceled" can also be transient during navigation — retry too.
|
||
lastMsg = e.message || String(e);
|
||
}
|
||
if (attempt < 2) await new Promise(r => setTimeout(r, 250 + attempt * 250));
|
||
}
|
||
throw new Error(
|
||
`Debugger attach failed after 3 attempts (tab ${tabId}). Last: ${lastMsg}. ` +
|
||
`If persistent: the page may be continuously reloading (dev-server mid-build — wait, then retry), ` +
|
||
`or the user canceled Chrome\'s debugger banner — reload Browser MCP (chrome://extensions/ → ↻) or restart Chrome.`
|
||
);
|
||
}
|
||
|
||
async function debuggerDetach(tabId) {
|
||
// Don't detach immediately — keep attached for subsequent commands.
|
||
// Will be cleaned up when tab closes or session ends.
|
||
}
|
||
|
||
function debuggerForceDetach(tabId) {
|
||
if (!debuggerAttached.has(tabId)) return;
|
||
debuggerAttached.delete(tabId);
|
||
try {
|
||
chrome.debugger.detach({ tabId });
|
||
} catch {}
|
||
}
|
||
|
||
// Sync local Set when Chrome auto-detaches (navigation, idle, devtools opened, etc.)
|
||
chrome.debugger.onDetach.addListener((source, reason) => {
|
||
if (source.tabId) {
|
||
debuggerAttached.delete(source.tabId);
|
||
if (reason && reason !== 'target_closed') {
|
||
console.log(`[MCP] Debugger auto-detached from tab ${source.tabId} (reason: ${reason})`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Methods that are safe to retry without double-effect.
|
||
// Side-effectful methods (Input.*, DOM.setFileInputFiles) must NEVER auto-retry:
|
||
// Chrome may detach AFTER processing the input (e.g., keystroke triggered navigation),
|
||
// and a blind retry would double-type or double-click.
|
||
// MAALT 9/9-2026 (fundet af Astra, reproduceret her): `Runtime.evaluate` stod paa listen,
|
||
// fordi de fleste kald er laesninger. Men vi kan ikke afgoere paa metodenavnet om et
|
||
// udtryk MUTERER — og flere af dem goer:
|
||
// * settle-udtrykket i debuggerClick FYRER reserveloesnings-klikket
|
||
// * scroll'ens reserveloesning kalder window.scrollBy
|
||
// * fill skriver i feltet gennem evalAttached
|
||
// Reproduktion: cdpSend(1,'Runtime.evaluate',{expression:'window.tael++'}) med en
|
||
// detach-fejl koerte udtrykket FIRE gange. Paa en SPA hvor debuggeren falder af, kunne
|
||
// det altsaa lande fire klik — paa en knap der maaske bestiller noget.
|
||
//
|
||
// Vi kan ikke skelne, saa standarden skal vaere sikker. Falder debuggeren af midt i en
|
||
// evaluering, faar kalderen fejlen og kan selv beslutte om det er forsvarligt at gentage.
|
||
// Prisen er en tabt gentagelse paa anti-automatiserings-sider; alternativet er et
|
||
// dobbeltklik, og de to ting er ikke lige slemme.
|
||
const RETRYABLE_CDP_METHODS = new Set([
|
||
'DOM.getDocument',
|
||
'DOM.querySelector',
|
||
'DOM.querySelectorAll',
|
||
'DOM.focus',
|
||
'DOM.describeNode',
|
||
'Runtime.enable',
|
||
'Page.captureScreenshot',
|
||
'Page.enable',
|
||
'Network.enable',
|
||
'Network.disable',
|
||
'Network.getResponseBody',
|
||
]);
|
||
|
||
// CDP wrapper with auto-recovery: re-attaches on detach errors.
|
||
// For read-only methods (whitelist above), retries once after re-attach.
|
||
// For side-effectful methods, only re-attaches and throws — caller must decide.
|
||
// MAALT 8/9-2026: `Input.dispatchMouseEvent` med mouseWheel indfrier ALDRIG sit loefte.
|
||
// `browser_scroll` med pixels ramte derfor serverens 30-sekunders-loft 6 kald ud af 6,
|
||
// paa baade en kort og en lang side — og den `window.scrollBy` der er skrevet til netop
|
||
// det tilfaelde, ligger i et `catch` og kunne aldrig naas. En haenger er ikke en exception.
|
||
// Fristen findes for at reserveloesningen kan naas. Alt der HAR en reserveloesning skal
|
||
// kunne naas via en frist, ikke kun via en fejl.
|
||
// Foerste udgave satte 1.500 ms paa ALT. Astra fandt 9/9 at det indfoerte en ny fejlklasse:
|
||
// dialog-ventetider bruger 3.000 ms, og et Page.captureScreenshot paa en tung side kan
|
||
// lovligt tage laengere. Fristen skar dem over, og skaermbilledets reserveloesning blev
|
||
// dermed naaet i almindelig drift — se rettelse B nedenfor.
|
||
//
|
||
// Den hang der blev MAALT var Input.dispatchMouseEvent med mouseWheel, som aldrig indfrier
|
||
// sit loefte. Laesekald har i forvejen deres egne ydre frister (evaluerTaalmodigt).
|
||
// Derfor: kort frist paa input-kald, og en rundhaandet bagstopper paa resten — stadig under
|
||
// serverens 30 s, saa kalderens reserveloesning kan naas.
|
||
const CDP_FRIST_INPUT_MS = 1500;
|
||
const CDP_FRIST_MS = 8000;
|
||
// MAALT 9/9 af reviewet: ét loft paa alt braekker tre ting. Et skaermbillede paa en tung
|
||
// side bruger lovligt mere end 8 s; `execute_script` med awaitPromise venter paa BRUGERENS
|
||
// egen kode, som serveren giver 30 s; og et enkelt tastetryk paa en side med validering
|
||
// pr. anslag kan lovligt overskride input-fristen. Fristen skal derfor kende kaldet.
|
||
const CDP_FRIST_TUNG_MS = 20000; // under serverens 30 s, men over alt lovligt
|
||
|
||
function cdpFrist(method, params) {
|
||
const m = String(method);
|
||
// Brugerens egen kode maa vente: awaitPromise betyder "vent paa dette loefte".
|
||
if (m === 'Runtime.evaluate' && params && params.awaitPromise) return CDP_FRIST_TUNG_MS;
|
||
if (m === 'Page.captureScreenshot' || m === 'Network.getResponseBody') return CDP_FRIST_TUNG_MS;
|
||
return m.startsWith('Input.') ? CDP_FRIST_INPUT_MS : CDP_FRIST_MS;
|
||
}
|
||
|
||
// Skaermbilledet har et budget for HELE kaeden, ikke kun pr. kald.
|
||
// MAALT 11/9 af Astra (sign-off, R5 F8): standardoptagelsen hang og koblede foerst fra efter 19 s. cdpSend gentog den,
|
||
// fordi den staar som sikker at gentage, og billedet kom efter ca. 38 s - serveren havde opgivet ved 30 s. I 1.29.0
|
||
// sendte en frist paa 8 s kaldet videre til fromSurface:false, som svarede paa et halvt sekund.
|
||
// Standardoptagelsen faar derfor 10 s. Reserven faar resten af budgettet, saa svaret naar frem foer serverens frist.
|
||
// MAALT 11/9 af Fable (e2e-review): haenger BEGGE optagelser, haevede 1.29.0 vinduet og leverede et billede efter 16,7 s.
|
||
// En frist afskar den sidste udvej her, saa der kom intet billede. Budgettet reserverer derfor haevMs til den haevede runde.
|
||
//
|
||
// MAALT 12/9 af Astra (tredje runde paa samme sted): en gentilslutning paa 12,5 s betoed at kandidaten opgav efter 26,9 s,
|
||
// hvor 1.29.0 leverede et billede efter 29,2 s. Aarsagen var ikke logikken, men TALLET: 26 s var valgt frit, ikke udledt.
|
||
// Hver runde fandt et nyt scenarie i de 4 sekunder vi gav bort. Budgettet er derfor nu udledt af det eneste tal der
|
||
// betyder noget - serverens egen frist pr. kald (sendToExtension i mcp-server/index.js) - minus den tid svaret skal
|
||
// bruge paa at komme tilbage gennem broen. Aendrer serverens frist sig, foelger budgettet med.
|
||
const SERVER_FRIST_MS = 30000; // sendToExtension(..., timeoutMs = 30000) i mcp-server/index.js
|
||
// MAALT 12/9 af Astra: hjemrejsen for en skaermbillede-stor nyttelast over den lokale WebSocket er 84-154 ms
|
||
// (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
|
||
// 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.
|
||
const SVARETS_HJEMREJSE_MS = 500; // udvidelse -> offscreen -> WebSocket -> server
|
||
function skaermbilledeFrister() {
|
||
return { foersteMs: 10000, samletMs: SERVER_FRIST_MS - SVARETS_HJEMREJSE_MS, haevMs: 8000 };
|
||
}
|
||
|
||
// wait_for_network har samme loft hos serveren (30 s). MAALT 11/9 af Astra (e2e-review): svaret kom efter 13 s, body-kaldet
|
||
// 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.
|
||
// Hele vaerktoejet har derfor ét budget, og body-kaldet faar kun det der er tilbage.
|
||
// Astra (efterproevning af c826f63): budgettet skar en body over der kom efter 27 s + 2 s, som 1.29.0 leverede efter 29 s.
|
||
// Body-kaldet faar derfor aldrig kortere tid end 1.29.0's frist (CDP_FRIST_MS) og aldrig mere end CDP_FRIST_TUNG_MS.
|
||
// MAALT 12/9 af Astra: her stod 28000 haardkodet - det SAMME frie tal som skaermbilledets budget lige var sluppet af med.
|
||
// Begge budgetter udledes nu af serverens egen frist, saa reglen gaelder hele vejen og ikke kun dér hvor den blev fundet.
|
||
function netvaerkFrister() {
|
||
return { budgetMs: SERVER_FRIST_MS - SVARETS_HJEMREJSE_MS, bodyMinMs: CDP_FRIST_MS, bodyMaxMs: CDP_FRIST_TUNG_MS };
|
||
}
|
||
|
||
|
||
// ── CDP-fristen er et SIGNAL, ikke en saetning ──────────────────────────────
|
||
// FUNDET 19/9 af Astra, efterproevet: fire steder afgjorde de, at en handling var
|
||
// UVIS - og dermed om svaret baerer `maaske_landet` - ved at regex-matche den danske
|
||
// tekst «svarede ikke inden» i fejlens besked. Astra oversatte teksten i hukommelsen
|
||
// og koerte samme press_key-forloeb: `maaske_landet` forsvandt, og advarslen mod blind
|
||
// gentagelse med den. Altsaa kunne en ren tekstrettelse - eller en oversaettelse -
|
||
// tavst slaa praecis den aerlighed fra som 1.29.2 handlede om.
|
||
//
|
||
// Det er husets egen lære, skrevet ned 7/9: et ord kan ikke baere en regel. Fejlen
|
||
// baerer nu et flag. Teksten maa aendres, oversaettes og omskrives frit; signalet
|
||
// foelger ikke med. Tekst-matchet bevares som bagstopper for fejl der er rejst
|
||
// andre steder fra.
|
||
function cdpFristFejl(besked) {
|
||
const e = new Error(besked);
|
||
e.cdpFrist = true;
|
||
return e;
|
||
}
|
||
function erCdpFrist(e) {
|
||
if (e && e.cdpFrist === true) return true;
|
||
return /did not respond within|svarede ikke inden/.test((e && e.message) || '');
|
||
}
|
||
|
||
function cdpMedFrist(tabId, method, params) {
|
||
let ur;
|
||
const frist = cdpFrist(method, params);
|
||
return Promise.race([
|
||
chrome.debugger.sendCommand({ tabId }, method, params),
|
||
new Promise((_, afvis) => {
|
||
// MAALT 11/9 (raa CDP-proeve i Chrome for Testing): Chrome leverer ikke Input.* til en fane i baggrunden.
|
||
// Kaldet haenger, og efter aktivering svarer det paa millisekunder. Fejlen siger det, saa agenten ved hvad den goer.
|
||
const hint = String(method).startsWith('Input.')
|
||
? ' (the tab is probably in the background - Chrome does not deliver mouse or keyboard input to a tab that is not active; call browser_switch_tab and try again)'
|
||
: '';
|
||
ur = setTimeout(() => afvis(cdpFristFejl(`CDP did not respond within ${frist} ms: ${method}${hint}`)), frist);
|
||
}),
|
||
]).finally(() => clearTimeout(ur));
|
||
}
|
||
|
||
async function cdpSend(tabId, method, params = {}) {
|
||
await debuggerAttach(tabId);
|
||
let lastMsg = '';
|
||
// 4 total attempts (initial + 3 retries) for read-only methods; backoff 100/300/500ms.
|
||
// Handles aggressive auto-detach on anti-automation sites (Apple ASC, Salesforce, etc.)
|
||
// where Chrome re-detaches between attach and command execution.
|
||
for (let attempt = 0; attempt < 4; attempt++) {
|
||
try {
|
||
return await cdpMedFrist(tabId, method, params);
|
||
} catch (e) {
|
||
const msg = e?.message || String(e);
|
||
const isDetachError =
|
||
msg.includes('not attached') ||
|
||
msg.includes('Detached') ||
|
||
msg.includes('detached') ||
|
||
msg.includes('Debugger is gone') ||
|
||
msg.includes('No tab with given id');
|
||
if (!isDetachError) throw e;
|
||
lastMsg = msg;
|
||
debuggerAttached.delete(tabId);
|
||
if (!RETRYABLE_CDP_METHODS.has(method)) {
|
||
// Side-effectful methods (Input.*) — re-attach for next caller but signal
|
||
// to handler so it can fall back to chrome.scripting (e.g., synthetic click).
|
||
try { await debuggerAttach(tabId); } catch {}
|
||
throw new Error(`Debugger detached during ${method} — not auto-retried (side-effect risk). Original: ${msg}`);
|
||
}
|
||
if (attempt < 3) {
|
||
await new Promise(r => setTimeout(r, 100 + attempt * 200));
|
||
try { await debuggerAttach(tabId); } catch (attachErr) {
|
||
throw new Error(`Re-attach failed during ${method}: ${attachErr.message}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
throw new Error(`Debugger detached repeatedly during ${method} (4 attempts). Last: ${lastMsg}`);
|
||
}
|
||
|
||
// Clean up debugger + session refs when tabs close
|
||
// Faner agenten selv lukkede via close_tab. En tom session betyder kun "arbejdet er slut"
|
||
// hvis det var BRUGEREN der lukkede den sidste fane.
|
||
const agentLukkedeFaner = new Set();
|
||
// Porte hvor VI selv har bedt serveren slippe porten, fordi sessionen var tom.
|
||
// Skelnen betyder alt i releaseSession: en frivillig frigivelse maa aldrig lukke
|
||
// faner, mens en uventet afbrydelse (chatten er vaek) netop skal rydde op.
|
||
const frivilligtFrigivet = new Set();
|
||
// Hvilken plads en chat sidst havde, husket paa dens pid — ikke paa porten.
|
||
//
|
||
// MAALT 8/9 (#17): siden porten slippes naar en session er faerdig, slettes sessionen.
|
||
// Kommer chatten tilbage, faar den det laveste LEDIGE nummer — saa en chat der var
|
||
// "Claude 3" vender tilbage som "Claude 1" i en anden farve, og brugeren kan ikke
|
||
// genkende sin egen fanegruppe. Identiteten hoerer til chatten, ikke til porten.
|
||
//
|
||
// Garantien er praecis "dit gamle nummer HVIS det er ledigt". Er det taget, vinder den
|
||
// nulevende session — ellers ville vi genindfoere den navnekollision som lavest-ledige-
|
||
// nummer blev indfoert for at loese.
|
||
const pladsPrPid = new Map();
|
||
function husketPlads(pid, nummer) {
|
||
if (typeof pid !== 'number') return;
|
||
pladsPrPid.set(pid, nummer);
|
||
// Kortet maa ikke vokse i det uendelige paa en langtlevende worker.
|
||
if (pladsPrPid.size > 60) pladsPrPid.delete(pladsPrPid.keys().next().value);
|
||
}
|
||
|
||
// ── Armerede dialog-haandterere, pr. fane ──────────────────────────────────────
|
||
// MAALT 22/8 af flowtesten: handle_dialog var ubrugelig som den var skrevet. Den
|
||
// BLOKEREDE i op til 10 sekunder mens den ventede paa en dialog — men en alert(),
|
||
// confirm() eller prompt() dukker foerst op naar man klikker paa noget, og klikket
|
||
// kan ikke ske mens kaldet blokerer. Man kunne altsaa hverken arme den foerst eller
|
||
// kalde den bagefter: naar dialogen foerst staar der, er hele fanen laast, og
|
||
// klik-vaerktoejet naar ikke frem. Vaerktoejet kunne kun lykkes hvis en ANDEN aabnede
|
||
// dialogen paa praecis det rigtige tidspunkt.
|
||
//
|
||
// Nu armerer den og vender tilbage med det samme. Lytteren bliver siddende og tager
|
||
// den naeste dialog paa fanen. `wait: true` giver den gamle blokerende adfaerd for
|
||
// de tilfaelde hvor dialogen allerede er undervejs.
|
||
const armeredeDialoger = new Map();
|
||
// Loeftet for den senest armerede dialog pr. fane. Ligger UDEN for armeredeDialoger,
|
||
// fordi lytteren afvaebner i samme oejeblik dialogen aabner — og klikket skal kunne
|
||
// vente paa svaret BAGEFTER.
|
||
const dialogLoefter = new Map(); // tabId -> Promise // tabId → { listener, timer, action }
|
||
|
||
function afvaebnDialog(tabId, grund) {
|
||
const a = armeredeDialoger.get(tabId);
|
||
if (!a) return;
|
||
try { chrome.debugger.onEvent.removeListener(a.listener); } catch {}
|
||
clearTimeout(a.timer);
|
||
armeredeDialoger.delete(tabId);
|
||
// MAALT 22/8: her stoppede funktionen. Loeftet blev ALDRIG opfyldt, saa en kalder
|
||
// med `wait: true` haengte for evigt ad to helt almindelige veje — fanen blev
|
||
// lukket, eller et andet handle_dialog armerede paa samme fane. Ingen fejl, intet
|
||
// svar, bare stilhed. Nu faar kalderen altid et svar.
|
||
if (grund && a.opfyld) a.opfyld({ ok: false, error: grund });
|
||
}
|
||
|
||
// Fylder sessions-kortet fra lageret UDEN at filtrere paa om fanerne stadig findes.
|
||
//
|
||
// `restoreSessions()` dropper sessioner uden GYLDIGE faner (`validTabIds.size > 0`), og i
|
||
// `tabs.onRemoved` er den fane vi lige har mistet netop den der ville goere sessionen
|
||
// ugyldig. Bruger man restoreSessions dér, forsvinder praecis den session man skal handle
|
||
// paa. Samme faelde som i alarm-lytteren, og derfor samme svar: laes lageret raat.
|
||
async function hydrerSessionerRaat() {
|
||
if (sessions.size) return;
|
||
const { sessions: gemte } = await chrome.storage.local.get({ sessions: {} });
|
||
for (const [port, d] of Object.entries(gemte)) {
|
||
const p = Number(port);
|
||
if (sessions.has(p)) continue;
|
||
sessions.set(p, {
|
||
tabIds: new Set(d.tabIds || []),
|
||
activeTabId: d.activeTabId ?? null,
|
||
groupId: d.groupId ?? null,
|
||
color: d.color,
|
||
label: d.label,
|
||
nummer: d.nummer ?? null,
|
||
pid: d.pid ?? null,
|
||
});
|
||
}
|
||
}
|
||
|
||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||
afvaebnDialog(tabId, 'the tab was closed before any dialog appeared');
|
||
const lukketAfAgenten = agentLukkedeFaner.delete(tabId);
|
||
debuggerAttached.delete(tabId);
|
||
// MAALT 8/9 (#15): loekken herunder loeb SYNKRONT paa `sessions`. Vaekker eventet en
|
||
// suspenderet service-worker, er kortet tomt, loekken koerer nul gange, og hverken
|
||
// nedlukningen eller fristen bliver sat — porten holdes saa til 4-timers-tomgangen.
|
||
// Agentens egen close_tab ramte det ikke (den kommer som mcp_command, der vaekker og
|
||
// gendanner foerst). Det var specifikt MENNESKET der lukkede den sidste fane, senere.
|
||
(async () => {
|
||
await hydrerSessionerRaat();
|
||
for (const [port, session] of sessions) {
|
||
if (!session.tabIds.has(tabId)) continue;
|
||
session.tabIds.delete(tabId);
|
||
if (session.tabIds.size === 0 && !lukketAfAgenten) {
|
||
// Last tab closed — tell offscreen to terminate the MCP server.
|
||
// Resulting WS-close triggers the existing session_disconnect → releaseSession path.
|
||
frivilligtFrigivet.add(port);
|
||
chrome.runtime.sendMessage({ type: 'terminate_mcp_session', port }).catch(() => {});
|
||
} else if (session.tabIds.size === 0) {
|
||
// ── Agenten lukkede selv sin sidste fane (MAALT 7/9-2026) ────────────────
|
||
// Her stod der intet, og det var med vilje: en agent der lukker en fane midt i
|
||
// et forloeb skal ikke miste browseren. Men serverens EGEN instruks siger til
|
||
// hver eneste agent: "ALWAYS close tabs when done". Hver velopdragen chat endte
|
||
// altsaa med at holde sin port til 4-timers-tomgangen udloeb — den dokumenterede
|
||
// god-praksis slog oprydningen ihjel, og 20 porte kunne staa optaget af chats
|
||
// der for laengst var faerdige.
|
||
//
|
||
// Nu: fem minutters henstand. Kommer der en ny fane inden da, aflyses den
|
||
// (se getSessionTab). Sker der intet, bedes serveren slippe porten — den DOER
|
||
// ikke, saa chatten kan hente browseren tilbage naar som helst.
|
||
//
|
||
// chrome.alarms og ikke setTimeout: en MV3-service-worker suspenderes, og en
|
||
// timer ville forsvinde med den.
|
||
chrome.alarms.create(`frigiv-${port}`, { delayInMinutes: 5 });
|
||
persistSessions();
|
||
} else {
|
||
persistSessions();
|
||
}
|
||
}
|
||
})().catch(() => {});
|
||
});
|
||
|
||
// Physical-key `code` for a character, US layout. We used to build this as
|
||
// `Key${char.toUpperCase()}`, which is only correct for letters: "1" became "Key1",
|
||
// "@" became "Key@", " " became "Key ". Frameworks that branch on event.code —
|
||
// masked inputs, shortcut handlers, several React form libraries — see an unknown
|
||
// code and drop the keystroke, so typing an email or URL misbehaved on strict SPAs.
|
||
// Shifted symbols report the code of the physical key they sit on ("@" is Digit2).
|
||
const CDP_CHAR_CODES = {
|
||
' ': 'Space', '\n': 'Enter', '\t': 'Tab',
|
||
'-': 'Minus', '_': 'Minus', '=': 'Equal', '+': 'Equal',
|
||
'[': 'BracketLeft', '{': 'BracketLeft', ']': 'BracketRight', '}': 'BracketRight',
|
||
'\\': 'Backslash', '|': 'Backslash', ';': 'Semicolon', ':': 'Semicolon',
|
||
"'": 'Quote', '"': 'Quote', ',': 'Comma', '<': 'Comma',
|
||
'.': 'Period', '>': 'Period', '/': 'Slash', '?': 'Slash',
|
||
'`': 'Backquote', '~': 'Backquote',
|
||
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', '%': 'Digit5',
|
||
'^': 'Digit6', '&': 'Digit7', '*': 'Digit8', '(': 'Digit9', ')': 'Digit0',
|
||
};
|
||
function cdpCodeForChar(ch) {
|
||
if (ch >= 'a' && ch <= 'z') return `Key${ch.toUpperCase()}`;
|
||
if (ch >= 'A' && ch <= 'Z') return `Key${ch}`;
|
||
if (ch >= '0' && ch <= '9') return `Digit${ch}`;
|
||
// Unknown (accented letters, CJK, emoji): omit it. CDP accepts a missing code,
|
||
// and an omitted code is honest where a fabricated one is actively misleading.
|
||
return CDP_CHAR_CODES[ch] || '';
|
||
}
|
||
|
||
// Types text as individual key events. Assumes the debugger is already attached —
|
||
// debuggerType() is the public wrapper that manages attach/detach.
|
||
async function typeCharsAttached(tabId, text) {
|
||
for (let i = 0; i < text.length; i++) {
|
||
const char = text[i];
|
||
const code = cdpCodeForChar(char);
|
||
await tastParAttached(tabId,
|
||
{ text: char, key: char, ...(code ? { code } : {}), unmodifiedText: char },
|
||
{ key: char, ...(code ? { code } : {}) });
|
||
// Human-like typing: random 30-120ms, occasional longer pause
|
||
const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
|
||
? 150 + Math.random() * 200 // thinking pause every ~10 chars
|
||
: 30 + Math.random() * 90; // normal keystroke
|
||
await new Promise(r => setTimeout(r, pause));
|
||
}
|
||
}
|
||
|
||
async function debuggerType(tabId, text) {
|
||
await debuggerAttach(tabId);
|
||
try {
|
||
await typeCharsAttached(tabId, text);
|
||
} finally {
|
||
await debuggerDetach(tabId);
|
||
}
|
||
}
|
||
|
||
// ── Naar en dialog kan aabne midt i klikket (MAALT 24/8) ─────────────────────
|
||
//
|
||
// Et confirm() fryser rendereren, og Chrome svarer ALDRIG paa den CDP-kommando der
|
||
// udloeste det. Maalt: browser_click haengte i 25,9 sekunder og gav aldrig svar —
|
||
// mens dialogen faktisk BLEV besvaret af den armerede haandtering (siden rapporterede
|
||
// window.__svar === true). Klikket var altsaa leveret; kun svaret var vaek.
|
||
//
|
||
// Er der armeret en dialog paa fanen, venter vi derfor kun kort. Kommer der intet
|
||
// svar, ER klikket leveret — det er jo netop dét der aabnede dialogen.
|
||
async function dispatchTaalmodigt(tabId, params) {
|
||
if (!armeredeDialoger.has(tabId)) {
|
||
return cdpSend(tabId, 'Input.dispatchMouseEvent', params);
|
||
}
|
||
let faerdig = false;
|
||
const kald = cdpSend(tabId, 'Input.dispatchMouseEvent', params)
|
||
.then((r) => { faerdig = true; return r; })
|
||
.catch(() => { faerdig = true; });
|
||
await Promise.race([kald, new Promise((r) => setTimeout(r, 1200))]);
|
||
return faerdig ? kald : { __dialogBlokerede: true };
|
||
}
|
||
|
||
// MAALT 28/8: `dispatchTaalmodigt` ovenfor lukkede deadlocken paa museklikket, men
|
||
// settle-opslaget i step 3 er OGSAA et renderer-kald — og maalingen viste at det er
|
||
// PRAECIS der klikket haenger (HAENGER@step3-settle). Deadlocken var kun lukket paa
|
||
// hovedstien.
|
||
//
|
||
// Foerste forsoeg gjorde kuren betinget af at vi kunne SE en dialog (armeret, eller en
|
||
// dispatch der blokerede). Den betingelse holder ikke: dispatchen kan naa at blive
|
||
// kvitteret, og lytteren kan naa at afvaebne, FOER rendereren gaar i staa. Saa faldt vi
|
||
// tilbage i det ubeskyttede kald og hang alligevel — maalt.
|
||
//
|
||
// Derfor er fristen nu betingelsesloes. Opslaget er et par linjers synkron JS: svarer
|
||
// rendereren, er den tilbage paa millisekunder. Bruger den over tre sekunder, er den
|
||
// blokeret — ikke langsom. Saa svarer vi aerligt i stedet for at vente i 30.
|
||
// Et klik der aabnede en dialog ER landet, saa framework-fallbacken skal ikke fyre oveni.
|
||
async function evaluerTaalmodigt(tabId, params, ms = 3000) {
|
||
let faerdig = false;
|
||
const kald = cdpSend(tabId, 'Runtime.evaluate', params)
|
||
.then((r) => { faerdig = true; return r; })
|
||
.catch((e) => { faerdig = true; return { __fejl: String(e?.message || e) }; });
|
||
await Promise.race([kald, new Promise((r) => setTimeout(r, ms))]);
|
||
if (faerdig) return kald;
|
||
return { result: { value: { landed: true, fallbackFired: false, rendererSvarede: false, observeret: false } } };
|
||
}
|
||
|
||
// Én regel for om et klik landede - brugt af click, click_xy og select_option. Astra, tredje runde:
|
||
// select_option havde stadig den gamle ("ikke falsk" = landet), saa et unknown klik blev til ok:true.
|
||
function klikLandede(r) {
|
||
return r?.landed === true || r?.detached === true;
|
||
}
|
||
|
||
// Teksten der foelger med et unknown klik. Staar ét sted, fordi den skal vaere ens for click, click_xy og select_option -
|
||
// tre kaldesteder med den samme regel har foer drevet fra hinanden (select_option havde den gamle i to udgaver).
|
||
const UVIST_NOTE = 'The click was sent, and the page changed on mousedown - but not from the click itself. That may be a ripple, and it may be a menu that opens on mousedown. Check the state before clicking again: a second click closes a menu that is already open.';
|
||
const UVERIFICERET_NOTE = 'The mouse button was sent, but the page could not be read afterwards (the lookup failed and the page did not navigate). ' +
|
||
'The click may have landed. Check the state before clicking again.';
|
||
|
||
// MAALT 12/9 af Astra: der er TO uvisheds-kanaler - `unknown` (kun mousedown aendrede noget) og `uverificeret`
|
||
// (settle-opslaget fejlede uden navigation, men museknappen ER sendt). Rettelsen roerte kun den foerste, saa den anden
|
||
// gav stadig et bart ok:false hvor 1.29.0 gav ok:true - og et bart nej faar agenten til at klikke igen.
|
||
// Betingelsen spoerger derfor paa landed === null, ikke paa ét flag: en fremtidig tredje kanal er daekket fra dag ét.
|
||
// MAALT 19/9: `note` kan nu gives af kalderen. Uden det laante select-grenens tredje udfald
|
||
// UVERIFICERET_NOTE, som siger "Museknappen blev sendt, men siden kunne ikke laeses bagefter".
|
||
// Begge dele er forkerte for en select: der er ingen museknap, og siden BLEV laest - det er
|
||
// netop derfor vi ved at noget flyttede sig. En rigtig dom med en forkert begrundelse sender
|
||
// laeseren det forkerte sted hen, og det er den samme fejlklasse som resten af 1.29.2.
|
||
function uvisVurdering(r, egenNote) {
|
||
if (!r || r.landed !== null) return null;
|
||
if (egenNote) return { maybe_landed: true, note: egenNote };
|
||
if (r.unknown) return { maybe_landed: true, note: UVIST_NOTE };
|
||
if (r.unverified) return { maybe_landed: true, note: UVERIFICERET_NOTE };
|
||
return { maybe_landed: true, note: 'The action was sent, but its effect could not be confirmed. Check the state before repeating it.' };
|
||
}
|
||
|
||
// MAALT 10/9 af Astra (anden runde): et settle-opslag der FEJLEDE gav null, og null blev til ok:true.
|
||
// Men den hyppigste grund til at opslaget fejler er at klikket navigerede - det er en virkning, og agenten
|
||
// maa ikke faa at vide at den skal klikke igen.
|
||
// Tredje runde: "detached", "closed" og "Cannot find" er IKKE bevis for navigation - afkobling sker ogsaa
|
||
// naar nogen aabner DevTools. Bevis er nu: fanen er lukket, adressen er skiftet, eller JavaScript-konteksten
|
||
// blev revet ned (det sker kun naar dokumentet blev udskiftet). Alt andet er unknown og siges som unknown.
|
||
async function tolkManglendeSettle(tabId, settle, urlFoer) {
|
||
const fejl = settle?.__fejl || 'settle-opslaget gav intet svar';
|
||
const fane = await chrome.tabs.get(tabId).catch(() => null);
|
||
const urlEfter = fane?.url ?? null;
|
||
const kontekstVaek = /Execution context was destroyed|Cannot find context with specified id|Inspected target navigated/i.test(fejl);
|
||
if (!fane || (urlFoer && urlEfter && urlEfter !== urlFoer) || kontekstVaek) {
|
||
return { landed: false, fallbackFired: false, detached: true, navigated: true };
|
||
}
|
||
return { landed: null, fallbackFired: false, unverified: true, fejl };
|
||
}
|
||
|
||
async function debuggerClick(tabId, x, y) {
|
||
await debuggerAttach(tabId);
|
||
// Er museknappen sendt ned, kan klikket vaere landet - saa maa en fejl bagefter ikke fore til et klik til.
|
||
let trykSendt = false;
|
||
try {
|
||
// Adressen foer klikket - et skift bagefter er bevis for en virkning (se tolkManglendeSettle).
|
||
const urlFoer = (await chrome.tabs.get(tabId).catch(() => null))?.url ?? null;
|
||
// 0. Capture the DEEPEST target element under the point BEFORE dispatching.
|
||
// Web-components (Google Ads <button-panel>, Material Web) keep their real
|
||
// <button> inside an (open) shadow root, so we pierce shadow roots to reach
|
||
// it. We stash it on window so the framework fallback (step 3) can verify it
|
||
// is still connected — if the trusted click already navigated/re-rendered,
|
||
// the ref is detached and we must NOT re-fire (avoids mis-clicks on the new
|
||
// view / double-submits).
|
||
await cdpSend(tabId, 'Runtime.evaluate', {
|
||
expression: `(() => {
|
||
let el = document.elementFromPoint(${x}, ${y});
|
||
let host = el;
|
||
for (let i = 0; i < 20 && host && host.shadowRoot; i++) {
|
||
const inner = host.shadowRoot.elementFromPoint(${x}, ${y});
|
||
if (!inner || inner === host) break;
|
||
el = inner; host = inner;
|
||
}
|
||
window.__bmcpClickTarget = el || null;
|
||
// FIX-13: watch whether the trusted click (step 2) actually lands on the target,
|
||
// so step 3's framework-fallback does NOT double-fire on elements that stay
|
||
// connected (toggles, checkboxes, add-to-cart, form fields).
|
||
window.__bmcpClicked = false;
|
||
try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
|
||
window.__bmcpClickListener = (ev) => {
|
||
try {
|
||
const t = ev.target;
|
||
if (el && (t === el || el.contains(t) || (ev.composedPath && ev.composedPath().includes(el)))) {
|
||
window.__bmcpClicked = true;
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
document.addEventListener('click', window.__bmcpClickListener, true);
|
||
})()`,
|
||
});
|
||
// 1. mouseMoved first (triggers hover state, required by some frameworks)
|
||
await dispatchTaalmodigt(tabId, {
|
||
type: 'mouseMoved', x, y,
|
||
});
|
||
await new Promise(r => setTimeout(r, 30));
|
||
// 2. mousePressed + mouseReleased. The `buttons` bitmask (1 while pressed,
|
||
// 0 on release) plus a small press→release gap are REQUIRED for Chrome to
|
||
// synthesize a *trusted* 'click' from the pair. Without them, web-components
|
||
// that gate on the trusted click event (Google Ads, Material Web) never fire.
|
||
trykSendt = true;
|
||
// Astra, tredje runde: fejlede mousePressed EFTER at vaere leveret, blev mouseReleased aldrig sendt,
|
||
// og siden stod tilbage med knappen nede (drag-tilstand). Knappen slippes nu altid.
|
||
let trykFejl = null;
|
||
try {
|
||
await dispatchTaalmodigt(tabId, {
|
||
type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
|
||
});
|
||
await new Promise(r => setTimeout(r, 30));
|
||
} catch (e) { trykFejl = e; }
|
||
try {
|
||
await dispatchTaalmodigt(tabId, {
|
||
type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
|
||
});
|
||
} catch (e) { trykFejl = trykFejl || e; }
|
||
if (trykFejl) throw trykFejl;
|
||
// 3. Framework fallback — only if the captured target is STILL connected (i.e.
|
||
// the trusted click in step 2 did not already handle it). Settle delay lets
|
||
// SPA re-renders (Google Ads) detach the element first. Fires a full pointer
|
||
// + mouse sequence on the shadow-pierced target, then React/Angular handlers.
|
||
await new Promise(r => setTimeout(r, 120));
|
||
// MAALT 9/9-2026: oprydningen (removeEventListener + delete) laa FOER reserveloesningen
|
||
// fyrede. Derfor kunne intet observere om det syntetiske klik virkede, og udtrykket
|
||
// svarede landed:false som et GAET. Paa en div-baseret dropdown betoed det
|
||
// {ok:true, landed:false} og en menu der aldrig aabnede — issue #19.
|
||
// Nu staar lytteren stadig paa document mens reserveloesningen fyrer, og laeses bagefter:
|
||
// det er forskellen paa en maaling og en antagelse. Der ryddes op paa hver udgang.
|
||
// NB: ingen backticks i udtrykket herunder — det ER et template literal.
|
||
const settle = await evaluerTaalmodigt(tabId, {
|
||
returnByValue: true,
|
||
expression: `(() => {
|
||
const el = window.__bmcpClickTarget;
|
||
const landed = window.__bmcpClicked === true;
|
||
const ryd = () => {
|
||
try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
|
||
try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
|
||
};
|
||
// Et billigt fingeraftryk af det et klik plejer at aendre: antal noder, synlig tekst,
|
||
// adressen, og om noget er aabnet/valgt. Bevidst groft — det skal kunne tages to gange
|
||
// paa faa millisekunder, ikke beskrive siden.
|
||
// MAALT 11/9 af Astra (R5 F6): tekst og feltvaerdier blev talt i LAENGDE, saa AAAA -> BBBB var
|
||
// usynlig, og et klik der virkede blev meldt som fejl. Nu hashes indholdet (FNV-1a, 32 bit).
|
||
const hash = (s) => {
|
||
let x = 2166136261;
|
||
for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = Math.imul(x, 16777619); }
|
||
return (x >>> 0).toString(36);
|
||
};
|
||
const aftryk = () => {
|
||
try {
|
||
return document.querySelectorAll('*').length + '|' +
|
||
hash(document.body ? String(document.body.innerText) : '') + '|' +
|
||
location.href + '|' +
|
||
document.querySelectorAll('[aria-expanded="true"],[aria-selected="true"],[open],.open,.active').length + '|' +
|
||
// Astra, anden runde: en afkrydsning eller en feltvaerdi aendrer hverken noder, tekst eller
|
||
// adresse - saa et klik der VIRKEDE blev meldt som fejl, og et nyt klik ville fortryde det.
|
||
document.querySelectorAll('input:checked,option:checked').length + '|' +
|
||
hash(Array.from(document.querySelectorAll('input,textarea,select')).map((e) => String(e.value || '')).join('\0'));
|
||
} catch (e) { return 'aftryk-fejlede'; }
|
||
};
|
||
if (landed) { ryd(); return { landed: true, fallbackFired: false }; } // FIX-13: trusted click already landed — do NOT double-fire
|
||
if (el === null) { ryd(); return { landed: false, fallbackFired: false, intetMaal: true }; } // intet element under punktet (fx uden for vinduet) - ingen virkning
|
||
if (!el || !el.isConnected) { ryd(); return { landed: false, fallbackFired: false, detached: true }; } // already navigated/handled — don't double-fire
|
||
const foerAftryk = aftryk();
|
||
const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
|
||
// Samme moenster som script-klikket (Astra 11/9): en PointerEvent uden pointerType er ikke en mus.
|
||
const mus = { ...opts, pointerType: 'mouse', isPrimary: true, pointerId: 1, button: 0 };
|
||
try { el.dispatchEvent(new PointerEvent('pointerdown', { ...mus, buttons: 1 })); } catch (e) {}
|
||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||
try { el.dispatchEvent(new PointerEvent('pointerup', { ...mus, buttons: 0 })); } catch (e) {}
|
||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||
// MAALT 10/9 i en rigtig browser: her stod BEGGE — dispatchEvent('click') OG el.click().
|
||
// Det er to klik-haendelser. Paa alt hvad der skifter tilstand (dropdowns, faneblade,
|
||
// afkrydsningsfelter, harmonikaer) aabner det foerste og det andet lukker igen, saa
|
||
// resultatet er intet. Det er aarsagen til at issue #19's div-dropdown "aldrig aabnede":
|
||
// den aabnede og lukkede inde i den samme reserveloesning.
|
||
// ét klik: aftryk 11|53|…|0 -> 11|69|…|1 (menuen aaben)
|
||
// to klik: aftryk 11|53|…|0 -> 11|53|…|0 (tilbage ved start)
|
||
// el.click() foretraekkes, fordi den ogsaa udloeser elementets aktiverings-adfaerd
|
||
// (foelg link, skift afkrydsning) — det goer en syntetisk MouseEvent ikke paalideligt.
|
||
// MAALT 11/9 af Astra (R5 F5): aftrykket til afgoerelsen herunder var foerAftryk fra FOER mousedown.
|
||
// En ripple-node fra mousedown lignede derfor en virkning af el.click(), React blev sprunget over,
|
||
// og svaret blev landed:true med nul handling. Afgoerelsen maaler nu kun hvad el.click() gjorde.
|
||
const foerKlik = aftryk();
|
||
if (typeof el.click === 'function') el.click();
|
||
else el.dispatchEvent(new MouseEvent('click', opts));
|
||
|
||
// MAALT 10/9 af Astra (anden runde): React-fiberens onClick blev kaldt UBETINGET efter el.click() -
|
||
// men el.click() udloeser allerede Reacts handler. To koersler, og en toggle endte hvor den startede.
|
||
// Framework-vejene er til elementer hvor det native klik INGEN virkning gav.
|
||
if (aftryk() === foerKlik) {
|
||
// React fiber fallback — find and call onClick handler directly
|
||
const fiberKey = Object.keys(el).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
|
||
if (fiberKey) {
|
||
let fiber = el[fiberKey];
|
||
for (let i = 0; i < 10 && fiber; i++) {
|
||
if (fiber.memoizedProps?.onClick) { fiber.memoizedProps.onClick(new MouseEvent('click', {bubbles:true})); break; }
|
||
fiber = fiber.return;
|
||
}
|
||
}
|
||
|
||
// Angular Material fallback — ripple + internal handlers
|
||
const ngKey = Object.keys(el).find(k => k.startsWith('__ng'));
|
||
if (ngKey || el.getAttribute('ng-click') || el.getAttribute('(click)')) {
|
||
const matRipple = el.closest && el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
|
||
if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
|
||
}
|
||
}
|
||
|
||
// Lytteren kan IKKE bruges her. Den udloeses af enhver dispatch paa maalet, og
|
||
// reserveloesningen dispatcher netop paa maalet — saa flaget ville vaere sandt fordi
|
||
// VI sendte noget, ikke fordi siden reagerede. Reproduceret 9/9 mod et <div> uden
|
||
// nogen handler: foer=false, efter=true.
|
||
// Derfor maales sidens REAKTION i stedet, med samme aftryks-greb som select_option
|
||
// allerede bruger: aendrede noget sig af det et klik plejer at aendre?
|
||
// MAALT 12/9 af Fable (e2e runde 3): her sammenlignedes med aftrykket fra FOER mousedown.
|
||
// Rettelsen af R5 F5 ovenfor gjaldt kun HVILKEN vej der blev proevet, ikke hvad vi SVAREDE. Uden en framework-vej
|
||
// at falde tilbage paa gav en ripple derfor stadig landed:true med nul handling.
|
||
// Tre udfald, ikke to: aendrede klikket (eller framework-vejen) noget, er det landet. Aendrede kun mousedown
|
||
// noget, ved vi det ikke - det kan vaere en ripple, men ogsaa en menu der aabner paa mousedown. Saa siges der
|
||
// hverken ja eller nej: landed null bliver til maaske_landet hos kalderen, med den maalte tilstand.
|
||
const efterAftryk = aftryk();
|
||
const klikketVirkede = efterAftryk !== foerKlik;
|
||
const kunMousedown = !klikketVirkede && foerKlik !== foerAftryk;
|
||
ryd();
|
||
return {
|
||
landed: kunMousedown ? null : klikketVirkede,
|
||
...(kunMousedown ? { unknown: true } : {}),
|
||
fallbackFired: true, aftrykFoer: foerAftryk, aftrykEfter: efterAftryk,
|
||
};
|
||
})()`,
|
||
});
|
||
const vaerdi = settle?.result?.value ?? null;
|
||
|
||
// MAALT 28/8: uden det her svarede klikket 5,5 sek FOER dialogen var besvaret, saa
|
||
// den NAESTE kommando ramte en stadig frossen side og ventede 15 sek forgaeves.
|
||
// Svarede rendereren ikke, ER der en dialog i vejen — saa vent til den er ude af
|
||
// verden, foer vi melder klikket faerdigt. Sidebonus: kalderen faar en side der
|
||
// rent faktisk er klar til naeste skridt.
|
||
if (vaerdi && vaerdi.rendererSvarede === false) {
|
||
const loefte = dialogLoefter.get(tabId);
|
||
if (loefte) {
|
||
await Promise.race([loefte, new Promise((r) => setTimeout(r, 8000))]);
|
||
dialogLoefter.delete(tabId);
|
||
}
|
||
}
|
||
return vaerdi ?? await tolkManglendeSettle(tabId, settle, urlFoer);
|
||
} catch (e) {
|
||
if (trykSendt && e && typeof e === 'object') e.trykSendt = true;
|
||
throw e;
|
||
} finally {
|
||
await debuggerDetach(tabId);
|
||
}
|
||
}
|
||
|
||
async function debuggerFocus(tabId, selector) {
|
||
await debuggerAttach(tabId);
|
||
try {
|
||
const { root } = await cdpSend(tabId, 'DOM.getDocument', {});
|
||
const { nodeId } = await cdpSend(tabId, 'DOM.querySelector', {
|
||
nodeId: root.nodeId, selector,
|
||
});
|
||
if (!nodeId) throw new Error('Element not found: ' + selector);
|
||
await cdpSend(tabId, 'DOM.focus', { nodeId });
|
||
return nodeId;
|
||
} catch (e) {
|
||
await debuggerDetach(tabId);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
// Runtime.evaluate that leaves attach state alone. debuggerEval() detaches in its
|
||
// finally, which would pull the debugger out from under a fill that is mid-flight.
|
||
async function evalAttached(tabId, expression) {
|
||
const result = await cdpSend(tabId, 'Runtime.evaluate', { expression, returnByValue: true });
|
||
if (result.exceptionDetails) {
|
||
throw new Error(result.exceptionDetails.text || 'Script execution failed');
|
||
}
|
||
return result.result?.value;
|
||
}
|
||
|
||
// En tast ned og op igen - og op igen UANSET hvad. MAALT 10/9 af Astra (anden runde): press_key fik
|
||
// keyUp-altid i foerste runde, men de tre hjaelpere der ogsaa sender taster gjorde ikke. Timede et
|
||
// keyDown ud efter at det VAR landet, forlod hjaelperen funktionen foer sit keyUp, og tasten sad fast
|
||
// for siden. Et fastsiddende Cmd goer det naeste tastetryk til en genvej.
|
||
async function tastParAttached(tabId, ned, op) {
|
||
let fejl = null;
|
||
try { await cdpSend(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ned }); } catch (e) { fejl = e; }
|
||
try { await cdpSend(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...op }); } catch (e) { fejl = fejl || e; }
|
||
if (fejl) throw fejl;
|
||
}
|
||
|
||
// Select-all + Backspace. Assumes the debugger is already attached.
|
||
async function clearFieldAttached(tabId) {
|
||
await tastParAttached(tabId, { key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS }, { key: 'a', code: 'KeyA' });
|
||
await tastParAttached(tabId, { key: 'Backspace', code: 'Backspace' }, { key: 'Backspace', code: 'Backspace' });
|
||
}
|
||
|
||
/**
|
||
* Tre-vejs dom paa hvad der FAKTISK staar i feltet. Delt af fill's to grene.
|
||
*
|
||
* FUNDET 13/9 af Astra: tekst-grenen fik den her dom om formiddagen, CSS-grenen ikke - og
|
||
* CSS-grenen er den mest brugte. Én funktion, saa de ikke kan drive fra hinanden igen.
|
||
*/
|
||
// `faktisk` staar ved siden af `vaerdi` fordi serverens instruktion til agenten siger
|
||
// «browser_fill with differs: true … read `faktisk`» (mcp-server/index.js:694). FUNDET 19/9:
|
||
// kun reservestien satte det felt; den her - den almindelige - svarede `vaerdi` alene, saa
|
||
// raadet pegede paa noget der ikke fandtes. Samme vaerdi, to navne, saa ingen af de to
|
||
// kodestier kraever at agenten ved hvilken den ramte. Fjern ikke `vaerdi`: det er
|
||
// API-overflade nogen kan laese i dag.
|
||
function fyldSvar(laest, oensket, ekstra, rammeHoerte) {
|
||
if (laest === undefined || laest === null) {
|
||
return { ok: true, ...ekstra, unknown: true,
|
||
note: 'The text was written, but the field could not be read afterwards, so it is unknown whether ' +
|
||
'it landed. Read the field with browser_execute_script if it matters.' };
|
||
}
|
||
if (laest === '') {
|
||
return { ok: false, ...ekstra, error: 'field-is-empty', value: laest, actual: laest,
|
||
note: 'The field was empty after the write. Chrome acknowledged both the insertion and ' +
|
||
'the keystrokes, but the field did not take them. The tab is probably in the background, where ' +
|
||
'Chrome does not deliver keystrokes. Call browser_switch_tab and try again.' };
|
||
}
|
||
if (laest === String(oensket)) {
|
||
// FUNDET 19/9 (issue #19): her stoppede vi. DOM-vaerdien var rigtig, saa vi svarede ja -
|
||
// og en React-styret formular opfoerte sig bagefter som om feltet var tomt. Trackeren er
|
||
// det eneste sted rammens egen opfattelse staar, og den modsiger DOM'en praecis naar
|
||
// rammen ikke har hoert efter. Det er positivt bevis for at det IKKE landede, ikke uvished.
|
||
if (rammeHoerte === false) {
|
||
return { ok: true, ...ekstra, value: laest, actual: laest, framework_did_not_hear: true,
|
||
note: 'The field SHOWS the right text, but the page\'s own state has not heard it: ' +
|
||
'React\'s value tracker is still on the old value. The form will ' +
|
||
'probably behave as if the field were empty, and the value may be discarded ' +
|
||
'on submit. Click the field with browser_click and type with browser_press_key, ' +
|
||
'or verify the result before moving on.' };
|
||
}
|
||
return { ok: true, ...ekstra, value: laest, actual: laest };
|
||
}
|
||
return { ok: true, ...ekstra, differs: true, value: laest, actual: laest,
|
||
note: 'The field contains something other than what was typed. The page has probably formatted ' +
|
||
'the value - or something was already there.' };
|
||
}
|
||
|
||
async function debuggerFill(tabId, selector, value) {
|
||
// Check if element is contenteditable (rich text editors: LinkedIn, Slack)
|
||
const isContentEditable = await debuggerEval(tabId, `
|
||
(function() {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
return el?.isContentEditable || el?.getAttribute('contenteditable') === 'true';
|
||
})()
|
||
`);
|
||
|
||
if (isContentEditable) {
|
||
// Rich text editors (Quill, ProseMirror, Slate, Draft.js) maintain internal
|
||
// state. Key events get ignored. execCommand('insertText') fires proper
|
||
// InputEvent that these editors handle correctly.
|
||
await debuggerEval(tabId, `
|
||
(function() {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
el.focus();
|
||
// Select all existing content and delete it
|
||
document.execCommand('selectAll', false, null);
|
||
document.execCommand('delete', false, null);
|
||
// Insert new text — fires InputEvent with inputType='insertText'
|
||
document.execCommand('insertText', false, ${JSON.stringify(value)});
|
||
})()
|
||
`);
|
||
// Samme form som den anden returvej, ellers faar kalderen to forskellige slags svar.
|
||
// rammeHoerte er null her med vilje: en contenteditable (LinkedIn, Slack) har ingen
|
||
// _valueTracker, saa teksten i elementet ER hele sandheden.
|
||
const ceTekst = await debuggerEval(tabId, `(() => {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
return el ? (el.textContent ?? null) : null;
|
||
})()`).catch(() => null);
|
||
return { value: ceTekst, rammeHoerte: null };
|
||
}
|
||
|
||
// Standard input/textarea — focus, clear, fill
|
||
await debuggerFocus(tabId, selector);
|
||
await debuggerAttach(tabId);
|
||
try {
|
||
await clearFieldAttached(tabId);
|
||
|
||
// ── Blev feltet FAKTISK tomt? (MAALT 8/9) ────────────────────────────────
|
||
//
|
||
// `clearFieldAttached` sender Cmd/Ctrl+A og Backspace som aegte tastetryk. Paa et
|
||
// React-styret felt tommer det ikke: maalt paa forbrugeragenten.dk/penge-tilbage gav
|
||
// to fill-kald efter hinanden vaerdien "test@example.dkanden@example.dk" — begge kald
|
||
// svarede ok:true. Vaerktoejet meldte succes og gjorde noget andet end det lovede.
|
||
//
|
||
// Et fill paa et TOMT felt var rent i samme maaling, saa fejlen sidder alene her.
|
||
// Derfor: laes tilbage, og ryd med den vej der virker paa styrede felter hvis
|
||
// tastetrykkene ikke slog igennem. Vi gaetter ikke paa hvorfor — vi tjekker.
|
||
const restVaerdi = await evalAttached(tabId, `
|
||
(function() {
|
||
const el = document.activeElement;
|
||
return el && 'value' in el ? el.value : '';
|
||
})()
|
||
`);
|
||
if (restVaerdi) {
|
||
await evalAttached(tabId, `
|
||
(function() {
|
||
const el = document.activeElement;
|
||
if (!el || !('value' in el)) return false;
|
||
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||
if (setter) setter.call(el, ''); else el.value = '';
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return true;
|
||
})()
|
||
`);
|
||
}
|
||
|
||
// Fast path: one trusted InputEvent instead of N key events. This is the same
|
||
// primitive set_combobox and set_date already rely on, it avoids per-key `code`
|
||
// mapping entirely, and it turns a 40-character value from ~3 seconds of
|
||
// keystrokes into a single call — which also shrinks the window in which the
|
||
// debugger can detach mid-fill.
|
||
await cdpSend(tabId, 'Input.insertText', { text: value });
|
||
|
||
// Verify something actually landed. Masked inputs, maxlength enforcement and
|
||
// autocompletes that filter per keydown can swallow an inserted string, and
|
||
// until now that failed silently: the caller got "ok" and the field stayed
|
||
// empty. Only an EMPTY field triggers the fallback — a field that transformed
|
||
// the text (phone/date masks reformatting it) did accept the input, and
|
||
// retyping it per character would produce the same transform for no gain.
|
||
// FUNDET 19/9 (issue #19): vi laeste DOM'ens vaerdi og svarede ja. Det er vores egen
|
||
// fejlklasse ét niveau op - feltet VISER den rigtige tekst, mens React's state aldrig
|
||
// hoerte det, og appen opfoerer sig som om feltet er tomt. Kvitteringen kom fra DOM'en,
|
||
// ikke fra rammen.
|
||
//
|
||
// Der findes et mekanisk svar. React haenger en `_valueTracker` paa elementet og
|
||
// opdaterer den NAAR den selv har behandlet aendringen. Stemmer trackeren med feltets
|
||
// vaerdi, har rammen hoert det. Stemmer den ikke, har den beviseligt ikke. Ingen tracker
|
||
// = ikke et rammestyret felt, og saa er DOM-vaerdien hele sandheden.
|
||
// Det er en egenskab, ikke et navn: et omdoebt bibliotek kan ikke skjule at trackeren
|
||
// og vaerdien er ude af trit.
|
||
let laesning = await evalAttached(tabId, `
|
||
(function() {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
if (!el) return null;
|
||
const v = ('value' in el) ? el.value : el.textContent;
|
||
let ramme = null;
|
||
try {
|
||
const t = el._valueTracker;
|
||
if (t && typeof t.getValue === 'function') ramme = (String(t.getValue()) === String(v));
|
||
} catch (e) { ramme = null; }
|
||
return { v: v, ramme: ramme };
|
||
})()
|
||
`);
|
||
let landed = laesning && typeof laesning === 'object' ? laesning.v : laesning;
|
||
let rammeHoerte = laesning && typeof laesning === 'object' ? laesning.ramme : null;
|
||
if (!landed) {
|
||
await clearFieldAttached(tabId);
|
||
await typeCharsAttached(tabId, value);
|
||
// FUNDET 13/9 af Astra: her stoppede vi. `typeCharsAttached` sender
|
||
// Input.dispatchKeyEvent-par - praecis den kommando der blev maalt i at lyve samme dag.
|
||
// Leveres tasterne heller ikke, kastes intet, og kalderen fik ok:true paa et tomt felt.
|
||
laesning = await evalAttached(tabId, `
|
||
(function() {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
if (!el) return null;
|
||
const v = ('value' in el) ? el.value : el.textContent;
|
||
let ramme = null;
|
||
try {
|
||
const t = el._valueTracker;
|
||
if (t && typeof t.getValue === 'function') ramme = (String(t.getValue()) === String(v));
|
||
} catch (e) { ramme = null; }
|
||
return { v: v, ramme: ramme };
|
||
})()
|
||
`).catch(() => null);
|
||
landed = laesning && typeof laesning === 'object' ? laesning.v : laesning;
|
||
rammeHoerte = laesning && typeof laesning === 'object' ? laesning.ramme : null;
|
||
}
|
||
return { value: landed, rammeHoerte: rammeHoerte };
|
||
} finally {
|
||
await debuggerDetach(tabId);
|
||
}
|
||
}
|
||
|
||
async function debuggerEval(tabId, expression) {
|
||
await debuggerAttach(tabId);
|
||
try {
|
||
const result = await cdpSend(tabId, 'Runtime.evaluate', {
|
||
expression,
|
||
returnByValue: true,
|
||
});
|
||
if (result.exceptionDetails) {
|
||
throw new Error(result.exceptionDetails.text || 'Script execution failed');
|
||
}
|
||
return result.result?.value;
|
||
} finally {
|
||
await debuggerDetach(tabId);
|
||
}
|
||
}
|
||
|
||
// Synthetic click via chrome.scripting — fallback when debugger detaches on
|
||
// anti-automation sites (Apple ASC, etc.). Loses isTrusted=true but works for
|
||
// the ~95% of sites that don't check it. Handles text= and :text() selectors.
|
||
/**
|
||
* Hvad staar der FAKTISK paa filfeltet bagefter?
|
||
*
|
||
* MAALT 13/9: `DOM.setFileInputFiles` kvitterer praecis som `Input.dispatchKeyEvent` gjorde -
|
||
* uden at love at filen kom paa. En sti der ikke findes, et `accept`-filter der afviser
|
||
* filtypen, eller sidens egen change-lytter der rydder feltet, giver alle en tom FileList,
|
||
* og baade upload_file og drop_file svarede ok:true paa den.
|
||
*
|
||
* Svarer null naar feltet ikke kan laeses. Det er UVIST, ikke nej.
|
||
*/
|
||
async function laesVedhaeftedeFiler(tabId, selector) {
|
||
try {
|
||
const [r] = await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func: (sel) => {
|
||
const el = document.querySelector(sel);
|
||
if (!el || !el.files) return null;
|
||
return { antal: el.files.length, navne: [...el.files].map((f) => f.name) };
|
||
},
|
||
args: [selector],
|
||
});
|
||
return r?.result ?? null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
/** Tre-vejs dom paa en vedhaeftning, delt af upload_file og drop_file. */
|
||
function fildSvar(vedhaeftet, oenskede, ekstra) {
|
||
if (!vedhaeftet) {
|
||
return { ok: true, ...ekstra, unknown: true,
|
||
note: 'The file was sent to the field, but the field could not be read afterwards, so it is unknown ' +
|
||
'whether it is attached. Check the page before sending again.' };
|
||
}
|
||
if (vedhaeftet.antal === 0) {
|
||
return { ok: false, ...ekstra, error: 'file-not-attached',
|
||
note: 'Chrome acknowledged the file, but the field is empty. The path may not exist, the field\'s ' +
|
||
'`accept` rejects the file type, or the page cleared the field itself. The file is NOT uploaded.' };
|
||
}
|
||
const svar = { ok: true, ...ekstra, attached: vedhaeftet.navne };
|
||
if (vedhaeftet.antal !== oenskede.length) {
|
||
svar.differs = true;
|
||
svar.note = `The field took ${vedhaeftet.antal} of ${oenskede.length} files. An \`accept\` filter or ` +
|
||
'a field without `multiple` discards the rest.';
|
||
}
|
||
return svar;
|
||
}
|
||
|
||
async function armerHaendelsesBevis(tabId, type) {
|
||
// Samme rolle for musen som armerTastBevis har for tasterne, og af samme grund: Chrome
|
||
// KVITTERER for en CDP-kommando uden at love at siden fik den. For tasterne loej det
|
||
// (maalt 13/9). For hover, double_click og right_click reddes svaret i dag af 1500 ms-fristen,
|
||
// altsaa af et uheld og ikke af en maaling - og et uheld er ikke en vagt. Et element der er
|
||
// daekket af et overlay, eller en side der sluger haendelsen, giver samme falske ja som
|
||
// press_key gav paa Enter.
|
||
const id = 'h' + Math.random().toString(36).slice(2, 10);
|
||
// FUNDET 13/9 af Astra: her stod `await ...; return id;`. Resultatet blev kasseret, saa
|
||
// antallet af armerede rammer kunne aldrig sammenlignes med antallet der svarede. En
|
||
// hovedramme der ikke kunne armeres, mens én iframe svarede "ingen haendelse", blev doemt
|
||
// `landed:false` - og et falsk NEJ er dyrere end det falske ja vi lige fjernede: agenten
|
||
// gentager handlingen, og en Enter der allerede sendte formularen sender den én gang til.
|
||
const armet = await chrome.scripting.executeScript({
|
||
target: { tabId, allFrames: true },
|
||
injectImmediately: true,
|
||
func: (nyId, haendelser) => {
|
||
const p = (window.__bmcpHaendelse ||= {});
|
||
if (p.fn) for (const t of [].concat(p.type || [])) window.removeEventListener(t, p.fn, true);
|
||
p.id = nyId; p.type = haendelser; p.antal = 0;
|
||
p.fn = () => { p.antal++; };
|
||
for (const t of haendelser) window.addEventListener(t, p.fn, true);
|
||
},
|
||
args: [id, [].concat(type)],
|
||
});
|
||
return { id, rammer: armet.length };
|
||
}
|
||
|
||
async function laesHaendelsesBevis(tabId, bevis) {
|
||
const { id, rammer } = bevis;
|
||
let svar;
|
||
try {
|
||
svar = await chrome.scripting.executeScript({
|
||
target: { tabId, allFrames: true },
|
||
// Uden den her venter Chrome paa at hver ramme bliver idle. En annonce-ramme der
|
||
// long-poller bliver det aldrig, og saa haenger dommen paa noget der intet har med
|
||
// haendelsen at goere. Armeringen havde den allerede; laesningen ikke.
|
||
injectImmediately: true,
|
||
func: (minId) => {
|
||
const p = window.__bmcpHaendelse;
|
||
if (!p || p.id !== minId) return { udskiftet: true };
|
||
if (p.fn) for (const t of [].concat(p.type || [])) window.removeEventListener(t, p.fn, true);
|
||
const r = { antal: p.antal };
|
||
p.fn = null; p.id = null;
|
||
return r;
|
||
},
|
||
args: [id],
|
||
});
|
||
} catch { return { landed: null }; }
|
||
// FUNDET 13/9 af Fable, bevist mod den aegte kode: `udskiftet` betyder kun "maerket er vaek".
|
||
// En ramme der ALDRIG havde maerket svarer det samme - og der kommer rammer til hele tiden
|
||
// (annoncer, GTM, reCAPTCHA, indlejret video; hover holder 500 ms, rigeligt). Hovedrammen
|
||
// sagde `antal:0`, annonce-rammen sagde `udskiftet`, og fire vaerktoejer svarede landed:true.
|
||
// Det var et falsk JA i selve beviset - den loegn 1.29.2 bliver udgivet for at fjerne.
|
||
//
|
||
// Kun HOVEDRAMMENS udskiftning er en navigation. En underrammes er stoej, og den taeller
|
||
// derfor hverken for eller imod: den er bare en ramme der ikke kunne maales.
|
||
const r = svar.filter((x) => x && x.result).map((x) => ({ frameId: x.frameId, ...x.result }));
|
||
if (r.some((x) => x.antal > 0)) return { landed: true };
|
||
if (r.some((x) => x.udskiftet && x.frameId === 0)) return { landed: true, navigeret: true };
|
||
// Kun rammer der faktisk MAALTE taeller. Faerre maalinger end armeringer betyder at en
|
||
// armeret ramme ikke kunne laeses - og det kan ikke skelnes fra "ingen fik den". UVIST.
|
||
const maalte = r.filter((x) => typeof x.antal === 'number');
|
||
if (!rammer || maalte.length < rammer) return { landed: null };
|
||
return { landed: false };
|
||
}
|
||
|
||
/** Samme tre-vejs dom som click og press_key. Ét sted, saa de ikke driver fra hinanden. */
|
||
function haendelsesSvar(bevis, grund, ekstra) {
|
||
if (bevis.landed === true) {
|
||
return { ok: true, landed: true, ...ekstra,
|
||
...(bevis.navigeret ? { note: 'The page navigated on the action.' } : {}) };
|
||
}
|
||
if (bevis.landed === null) {
|
||
return { ok: true, landed: null, maybe_landed: true, ...ekstra,
|
||
note: 'The action was sent, but whether the page received it could not be read. Check the page before trying again.' };
|
||
}
|
||
return { ok: false, landed: false, ...ekstra, error: grund,
|
||
note: 'Chrome acknowledged it, but no listener in the tab received the event. The tab is probably in the ' +
|
||
'background, and Chrome does not deliver mouse or keyboard input to a tab that is not the visible one in its ' +
|
||
'window. Call browser_switch_tab and try again.' };
|
||
}
|
||
|
||
async function armerTastBevis(tabId, forventet) {
|
||
// MAALT 13/9, live og kalibreret mod et kendt-sandt tilfaelde: i en baggrundsfane KVITTERER
|
||
// Chrome for Input.dispatchKeyEvent og leverer ikke tasten. Ingen fejl, ingen frist. Musen
|
||
// haenger og bliver derfor opdaget af 1500 ms-fristen; tasten goer ikke, og press_key svarede
|
||
// ok:true paa en tast der aldrig kom frem. Det er den fejlklasse 1.29.1 blev udgivet for at
|
||
// fjerne, og den var tilbage i vaerktoejet selv.
|
||
//
|
||
// Musen maaler sidens REAKTION (aftryk foer/efter). En tast maa lovligt ikke aendre noget -
|
||
// Tab og Escape goer typisk intet synligt - saa reaktion duer ikke. Vi maaler LEVERING.
|
||
// Lytteren plantes i udvidelsens EGEN verden (ISOLATED): siden kan hverken se eller fjerne
|
||
// den, og sidens CSP rammer den ikke. Capture paa window er foerste led i kaeden, saa et
|
||
// stopPropagation i siden kan ikke skjule at tasten blev leveret.
|
||
const id = 'k' + Math.random().toString(36).slice(2, 10);
|
||
// FUNDET 13/9 af Astra: her stod `p.sidst = e.key`, og dommen var `p.sidst === forventet`.
|
||
// Lytteren sidder paa window i capture for hele fanen, saa skriver brugeren selv mens
|
||
// agenten trykker, overskrives `sidst` og en tast der LANDEDE meldes som ikke-leveret.
|
||
// Nu saettes et flag naar den ventede tast ses, og det kan ikke overskrives igen.
|
||
const armet = await chrome.scripting.executeScript({
|
||
target: { tabId, allFrames: true }, // fokus kan staa i en iframe
|
||
injectImmediately: true,
|
||
func: (nyId, vent) => {
|
||
const p = (window.__bmcpTast ||= {});
|
||
if (p.fn) window.removeEventListener('keydown', p.fn, true);
|
||
p.id = nyId; p.antal = 0; p.traf = false;
|
||
p.fn = (e) => { p.antal++; if (e.key === vent) p.traf = true; };
|
||
window.addEventListener('keydown', p.fn, true);
|
||
},
|
||
args: [id, forventet],
|
||
});
|
||
return { id, rammer: armet.length };
|
||
}
|
||
|
||
async function laesTastBevis(tabId, bevis) {
|
||
const { id, rammer } = bevis;
|
||
let svar;
|
||
try {
|
||
svar = await chrome.scripting.executeScript({
|
||
target: { tabId, allFrames: true },
|
||
injectImmediately: true,
|
||
func: (minId) => {
|
||
const p = window.__bmcpTast;
|
||
if (!p || p.id !== minId) return { udskiftet: true }; // navigation tog lytteren med
|
||
if (p.fn) window.removeEventListener('keydown', p.fn, true);
|
||
const r = { antal: p.antal, traf: !!p.traf };
|
||
p.fn = null; p.id = null;
|
||
return r;
|
||
},
|
||
args: [id],
|
||
});
|
||
} catch { return { landed: null }; } // kunne ikke laeses: unknown, ikke nej
|
||
const r = svar.filter((x) => x && x.result).map((x) => ({ frameId: x.frameId, ...x.result }));
|
||
if (r.some((x) => x.antal > 0 && x.traf)) return { landed: true };
|
||
// Samme regel som for musen: kun hovedrammens udskiftning er en navigation.
|
||
if (r.some((x) => x.udskiftet && x.frameId === 0)) return { landed: true, navigeret: true };
|
||
const maalte = r.filter((x) => typeof x.antal === 'number');
|
||
if (!rammer || maalte.length < rammer) return { landed: null };
|
||
return { landed: false };
|
||
}
|
||
|
||
async function scriptingClick(tabId, selector) {
|
||
try {
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
world: 'MAIN',
|
||
func: (sel) => {
|
||
let el;
|
||
if (sel.startsWith('text=')) {
|
||
const text = sel.slice(5).trim();
|
||
el = Array.from(document.querySelectorAll('button, a, [role="button"], [role="menuitem"], [role="tab"], [role="option"], input, label, span, div, p, li, td'))
|
||
.find(e => (e.textContent || '').trim() === text);
|
||
} else {
|
||
const m = sel.match(/^([\w-]+):text\(([^)]+)\)$/);
|
||
if (m) {
|
||
const needle = m[2].trim();
|
||
el = Array.from(document.querySelectorAll(m[1]))
|
||
.find(e => (e.textContent || '').trim().includes(needle));
|
||
} else {
|
||
el = document.querySelector(sel);
|
||
}
|
||
}
|
||
if (!el) return { ok: false, reason: 'not_found' };
|
||
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||
// Sign-off 11/9 (Astra og Fable, begge reproduceret): paa en baggrundsfane blev et klik der krævede isTrusted,
|
||
// eller kun lyttede paa pointerdown, meldt ok:true uden nogen virkning. Klikket maaler nu sidens reaktion med
|
||
// samme aftryk og samme regel som debuggerens reserve (debuggerClick) - ingen backticks herinde.
|
||
const hash = (s) => {
|
||
let x = 2166136261;
|
||
for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = Math.imul(x, 16777619); }
|
||
return (x >>> 0).toString(36);
|
||
};
|
||
const aftryk = () => {
|
||
try {
|
||
return document.querySelectorAll('*').length + '|' +
|
||
hash(document.body ? String(document.body.innerText) : '') + '|' +
|
||
location.href + '|' +
|
||
document.querySelectorAll('[aria-expanded="true"],[aria-selected="true"],[open],.open,.active').length + '|' +
|
||
document.querySelectorAll('input:checked,option:checked').length + '|' +
|
||
hash(Array.from(document.querySelectorAll('input,textarea,select')).map((e) => String(e.value || '')).join(' '));
|
||
} catch (e) { return 'aftryk-fejlede'; }
|
||
};
|
||
const opts = { bubbles: true, cancelable: true, composed: true, view: window };
|
||
// Et rigtigt klik sender pointerdown foer mousedown; Radix/shadcn aabner paa pointerdown.
|
||
// MAALT 11/9 af Astra (efterproevning af 9814636): uden pointerType ("") handlede en side der reagerer paa ikke-mus-
|
||
// pointere OG paa click to gange. En rigtig mus sender pointerType "mouse".
|
||
const mus = { ...opts, pointerType: 'mouse', isPrimary: true, pointerId: 1, button: 0 };
|
||
try { el.dispatchEvent(new PointerEvent('pointerdown', { ...mus, buttons: 1 })); } catch (e) {}
|
||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||
try { el.dispatchEvent(new PointerEvent('pointerup', { ...mus, buttons: 0 })); } catch (e) {}
|
||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||
// Aftrykket tages lige foer el.click(). MAALT 11/9 af Astra (e2e-review): taget foer mousedown blev en ripple klikbevis
|
||
// (ok:true, nul handlinger; 1.29.0: fejl). En side der reagerer paa pointerdown, bliver derfor aerligt unknown.
|
||
const foer = aftryk();
|
||
el.click();
|
||
// Maales i samme oejeblik som klikket. Astra (efterproevning af c1496d4): en anden maaling 200 ms senere gjorde et
|
||
// uafhaengigt ur til klikbevis og et klik der navigerede i ventetiden til en fejl. En sen React-opdatering giver
|
||
// derfor landed:false og maaske_landet - aerligt, aldrig en falsk succes.
|
||
return { ok: true, tag: el.tagName, landed: aftryk() !== foer, detached: el.isConnected === false };
|
||
},
|
||
args: [selector],
|
||
});
|
||
return result?.result || { ok: false, reason: 'no_result' };
|
||
} catch (e) {
|
||
return { ok: false, reason: 'exception', error: e.message };
|
||
}
|
||
}
|
||
|
||
// Try executeScript first, fall back to debugger on CSP error
|
||
async function safeExecuteScript(tabId, func, args = [], world = 'MAIN') {
|
||
try {
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func,
|
||
args,
|
||
...(world === 'MAIN' ? { world: 'MAIN' } : {}),
|
||
});
|
||
return { result: result.result, usedDebugger: false };
|
||
} catch (e) {
|
||
if (e.message?.includes('Content Security Policy') || e.message?.includes('unsafe-eval')) {
|
||
// CSP blocked — this is expected on Google, Stripe, Slack
|
||
return { cspBlocked: true };
|
||
}
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
// ── Smart Selector Resolution ─────────────────────────────────────────────
|
||
// Supports CSS selectors AND text-based selectors:
|
||
// "button:text(Get started)" → finds button containing "Get started"
|
||
// "#my-id" → standard CSS selector
|
||
// "text=Submit" → any element containing "Submit"
|
||
|
||
function buildTextFinderJS(textPattern, tagFilter) {
|
||
const escaped = JSON.stringify(textPattern);
|
||
const wantTag = tagFilter ? JSON.stringify(tagFilter.toUpperCase()) : 'null';
|
||
return `(function() {
|
||
const text = ${escaped};
|
||
const wantTag = ${wantTag};
|
||
// Interactive controls we prefer to actually click. Fixes the class of bug where a
|
||
// text match lands on a large CONTAINER (e.g. Angular Material <mat-nav-list>,
|
||
// toolbar, list-item) whose center is NOT over the real <button> — so the trusted
|
||
// click misses and menus/dropdowns never open.
|
||
const CLICKABLE = 'a,button,summary,label,[role="button"],[role="menuitem"],' +
|
||
'[role="menuitemcheckbox"],[role="menuitemradio"],[role="option"],[role="tab"],' +
|
||
'[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[onclick],' +
|
||
'[mat-button],[mat-raised-button],[mat-stroked-button],[mat-flat-button],' +
|
||
'[mat-icon-button],[mat-fab],[mat-mini-fab],[mat-menu-item],[mat-list-item],' +
|
||
'mat-checkbox,mat-slide-toggle,mat-radio-button';
|
||
function collectAll(root, results) {
|
||
for (const el of root.querySelectorAll('*')) {
|
||
results.push(el);
|
||
if (el.shadowRoot) collectAll(el.shadowRoot, results);
|
||
}
|
||
return results;
|
||
}
|
||
const all = collectAll(document, []);
|
||
const tagOk = (el) => !wantTag || el.tagName === wantTag;
|
||
// Map a matched element to the ACTIONABLE control: itself if clickable, else the
|
||
// nearest clickable ancestor (only if its own text isn't much larger than the match,
|
||
// so we don't grab a whole toolbar), else a clickable descendant.
|
||
function toClickable(el) {
|
||
if (el.matches && el.matches(CLICKABLE)) return el;
|
||
const anc = el.closest && el.closest(CLICKABLE);
|
||
if (anc && (anc.textContent || '').trim().length <= text.length + 40) return anc;
|
||
const desc = el.querySelector && el.querySelector(CLICKABLE);
|
||
if (desc) return desc;
|
||
return el;
|
||
}
|
||
function pick(test) {
|
||
const matches = all.filter(el => tagOk(el) && test((el.textContent || '').trim()));
|
||
if (!matches.length) return null;
|
||
// Prefer the INNERMOST matches (an element that is not an ancestor of another
|
||
// match) — this is what "prefer leaf nodes" was supposed to do.
|
||
const inner = matches.filter(el => !matches.some(o => o !== el && el.contains && el.contains(o)));
|
||
const pool = inner.length ? inner : matches;
|
||
// Prefer a match that resolves to a real interactive control.
|
||
for (const el of pool) {
|
||
const c = toClickable(el);
|
||
if (c && c.matches && c.matches(CLICKABLE)) return c;
|
||
}
|
||
return toClickable(pool[0]);
|
||
}
|
||
// Exact match first, then partial fallback.
|
||
return pick(t => t === text) || pick(t => t && t.includes(text));
|
||
})()`;
|
||
}
|
||
|
||
function parseSelector(selector) {
|
||
// "button:text(Get started)" → { tag: 'button', text: 'Get started' }
|
||
const tagTextMatch = selector.match(/^(\w+):text\((.+)\)$/);
|
||
if (tagTextMatch) return { type: 'text', tag: tagTextMatch[1], text: tagTextMatch[2] };
|
||
|
||
// "text=Submit" → { text: 'Submit' }
|
||
if (selector.startsWith('text=')) return { type: 'text', tag: null, text: selector.slice(5) };
|
||
|
||
// Standard CSS selector
|
||
return { type: 'css', selector };
|
||
}
|
||
|
||
async function resolveElement(tabId, selectorStr) {
|
||
const parsed = parseSelector(selectorStr);
|
||
|
||
if (parsed.type === 'css') {
|
||
// Standard CSS with shadow DOM traversal — try executeScript first, debugger fallback
|
||
const deepQueryFn = (sel) => {
|
||
function queryDeep(root, s) {
|
||
const el = root.querySelector(s);
|
||
if (el) return el;
|
||
for (const node of root.querySelectorAll('*')) {
|
||
if (node.shadowRoot) {
|
||
const found = queryDeep(node.shadowRoot, s);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
const el = queryDeep(document, sel);
|
||
if (!el) return null;
|
||
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||
const r = el.getBoundingClientRect();
|
||
// MAALT 21/8: et skjult element har rect 0x0 ved (0,0), saa midtpunktet blev (0,0)
|
||
// og debuggerClick sendte et AEGTE museklik i sidens oeverste venstre hjoerne —
|
||
// paa hvad der nu laa der (logo, menu, link) — og svarede ok:true. Det er ikke en
|
||
// rapporteringsfejl men en handlingsfejl: vi klikker et andet sted end der blev bedt om.
|
||
if (r.width <= 0 || r.height <= 0) {
|
||
return { found: false, hidden: true, tag: el.tagName, rect: { w: r.width, h: r.height } };
|
||
}
|
||
return { x: r.x + r.width / 2, y: r.y + r.height / 2, tag: el.tagName, found: true };
|
||
};
|
||
|
||
const scriptResult = await safeExecuteScript(tabId, deepQueryFn, [parsed.selector]);
|
||
|
||
if (scriptResult.cspBlocked) {
|
||
const sel = JSON.stringify(parsed.selector);
|
||
const result = await debuggerEval(tabId, `
|
||
(function() {
|
||
function queryDeep(root, s) {
|
||
const el = root.querySelector(s);
|
||
if (el) return el;
|
||
for (const node of root.querySelectorAll('*')) {
|
||
if (node.shadowRoot) { const f = queryDeep(node.shadowRoot, s); if (f) return f; }
|
||
}
|
||
return null;
|
||
}
|
||
const el = queryDeep(document, ${sel});
|
||
if (!el) return null;
|
||
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||
const r = el.getBoundingClientRect();
|
||
if (r.width <= 0 || r.height <= 0) {
|
||
return { found: false, hidden: true, tag: el.tagName, rect: { w: r.width, h: r.height } };
|
||
}
|
||
return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, found: true };
|
||
})()
|
||
`);
|
||
return result ? { ...result, method: 'debugger' } : null;
|
||
}
|
||
return scriptResult.result;
|
||
}
|
||
|
||
// Text-based selector — always use debugger (more reliable, no CSP issues)
|
||
const finderJS = buildTextFinderJS(parsed.text, parsed.tag);
|
||
const result = await debuggerEval(tabId, `
|
||
(function() {
|
||
const el = ${finderJS};
|
||
if (!el) return null;
|
||
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||
const r = el.getBoundingClientRect();
|
||
if (r.width <= 0 || r.height <= 0) {
|
||
return { found: false, hidden: true, tag: el.tagName, text: el.textContent?.trim().slice(0, 80), rect: { w: r.width, h: r.height } };
|
||
}
|
||
return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, text: el.textContent?.trim().slice(0, 80), found: true };
|
||
})()
|
||
`);
|
||
return result ? { ...result, method: 'debugger' } : null;
|
||
}
|
||
|
||
// ── Offscreen Document Setup ───────────────────────────────────────────────
|
||
|
||
// MAALT 21/8: her stod kun `if (!await chrome.offscreen.hasDocument()) createDocument()`.
|
||
// Den spurgte om dokumentet FANDTES — aldrig om det SVAREDE. Et dokument hvis script
|
||
// aldrig blev indlaest (en CSP-afvisning raekker) taeller stadig som eksisterende, saa
|
||
// hjerteslaget hvert minut gjorde ingenting, for evigt. Udvidelsen saa levende ud i
|
||
// chrome://extensions, men havde ingen WebSocket og kunne ikke naas af noget — heller
|
||
// ikke af reload_extension, som netop kraever den forbindelse den mangler. Eneste vej
|
||
// ud var ↻ i haanden.
|
||
//
|
||
// Nu spoerges dokumentet om det er der. Svarer det ikke, erstattes det.
|
||
async function offscreenSvarer() {
|
||
try {
|
||
const svar = await Promise.race([
|
||
chrome.runtime.sendMessage({ type: 'bmcp_ping' }),
|
||
new Promise((_, afvis) => setTimeout(() => afvis(new Error('intet svar')), 1500)),
|
||
]);
|
||
if (svar?.ok !== true) return false;
|
||
// "Svarer den?" er ikke nok — den skal ogsaa vaere den udgave vi koerer nu.
|
||
// En bro fra en aeldre udgave svarer lige saa villigt, og saa blev den aldrig
|
||
// udskiftet. Oplyser den ingen version, er den fra foer 1.27.1 og altsaa gammel.
|
||
let vores = null;
|
||
try { vores = chrome.runtime.getManifest().version; } catch {}
|
||
if (!vores) return true; // kan vi ikke sammenligne, saa lad den vaere
|
||
return svar.version === vores;
|
||
} catch {
|
||
return false; // ingen modtager, eller den svarede ikke i tide
|
||
}
|
||
}
|
||
|
||
// Genskabelsen er BEGRAENSET, og det er ikke pynt.
|
||
//
|
||
// "Svarer ikke" betyder ikke altid "doed". En AELDRE offscreen.js — fra foer
|
||
// ping-lytteren fandtes — svarer heller ikke, og Chrome kan servere den fra cache
|
||
// hen over en genindlaesning (maalt 21/8). Uden en graense ville hjerteslaget saa
|
||
// lukke og genskabe en fuldt fungerende bro hvert minut, for evigt, og rive
|
||
// WebSocket-forbindelsen ned hver gang. Kuren ville vaere vaerre end sygdommen.
|
||
//
|
||
// Derfor: hoejst tre forsoeg i traek. Er dokumentet aegte doedt, er ét nok. Er det
|
||
// bare gammelt, koster det tre korte afbrydelser og saa faar det fred et stykke tid.
|
||
//
|
||
// MAALT 22/8 — og det var en fejl i denne blok: graensen var PERMANENT. Naaede
|
||
// taelleren tre, blev der aldrig forsoegt igen, og taelleren nulstilles kun naar en
|
||
// ping lykkes — hvilket en doed bro pr. definition aldrig goer. Resultatet var en
|
||
// doed bro der laa doed for evigt, hvor symptomet for brugeren er "browser-mcp
|
||
// virker ikke", og hvor den eneste udvej var at genindlaese udvidelsen i haanden.
|
||
// Praecis den tilstand vaernet skulle forhindre.
|
||
//
|
||
// Rettelsen: graensen er nu tidsbestemt, ikke endelig. Efter tre forsoeg holder vi
|
||
// pause — og naar pausen er ovre, proever vi igen. En gammel-men-fungerende bro
|
||
// faar altsaa ro i pausen i stedet for at blive revet ned hvert minut, og en aegte
|
||
// doed bro er hoejst én pause fra at blive erstattet. Begge hensyn er i behold.
|
||
const MAX_OFFSCREEN_GENSKAB = 3;
|
||
const OFFSCREEN_PAUSE_MS = 10 * 60 * 1000;
|
||
|
||
// Et omraade paa mere end dette ville faa HVER brugers browser til at probe tusindvis af porte hvert andet sekund.
|
||
const PORTE_MAX_SPAEND = 200;
|
||
/** Laeser et alternativt portomraade fra chrome.storage.local. Ugyldigt = standarden (9876-9895) staar. */
|
||
async function portOmraadeFraLager() {
|
||
try {
|
||
const { bmcpPorte } = await chrome.storage.local.get({ bmcpPorte: null });
|
||
const m = /^(\d{4,5})-(\d{4,5})$/.exec(String(bmcpPorte ?? ''));
|
||
if (!m) return null;
|
||
const fra = Number(m[1]), til = Number(m[2]);
|
||
if (fra < 1024 || til > 65535 || til < fra || til - fra >= PORTE_MAX_SPAEND) return null;
|
||
return fra + '-' + til;
|
||
} catch { return null; }
|
||
}
|
||
|
||
async function ensureOffscreen() {
|
||
const findes = await chrome.offscreen.hasDocument();
|
||
|
||
if (findes) {
|
||
if (await offscreenSvarer()) {
|
||
await chrome.storage.local.set({ offscreenGenskabt: 0 }); // levende — nulstil
|
||
return;
|
||
}
|
||
// Taelleren skal ligge i storage, ikke i en modul-variabel: service-workeren
|
||
// genstartes hele tiden, og en variabel ville nulstilles ved hver genstart —
|
||
// altsaa ingen graense i praksis.
|
||
let { offscreenGenskabt = 0 } =
|
||
await chrome.storage.local.get({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
|
||
const { offscreenPauseTil = 0 } =
|
||
await chrome.storage.local.get({ offscreenPauseTil: 0 });
|
||
|
||
const nu = Date.now();
|
||
if (offscreenPauseTil > nu) return; // midt i pausen — lad broen vaere
|
||
|
||
if (offscreenPauseTil > 0) {
|
||
// Pausen er udloebet. Taelleren SKAL nulstilles her, foer graensen tjekkes —
|
||
// ellers rammer vi graensen igen med det samme, saetter endnu en pause, og
|
||
// graensen er i praksis permanent alligevel, bare med et ekstra skridt.
|
||
// (Maalt 22/8: det var praecis den fejl den foerste udgave af rettelsen havde.
|
||
// Testen "naar pausen er ovre, proeves der igen" fangede den.)
|
||
offscreenGenskabt = 0;
|
||
await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
|
||
}
|
||
|
||
if (offscreenGenskabt >= MAX_OFFSCREEN_GENSKAB) {
|
||
// Pause i stedet for at give op. Taelleren nulstilles samtidig, saa naeste
|
||
// runde faar sine egne tre forsoeg — ellers ville graensen vaere permanent
|
||
// alligevel, bare med et ekstra skridt.
|
||
console.warn(`[BG] offscreen-dokumentet svarer stadig ikke efter ${MAX_OFFSCREEN_GENSKAB} ` +
|
||
`attempts - pausing for ${OFFSCREEN_PAUSE_MS / 60000} min, then trying again. ` +
|
||
'In a hurry: chrome://extensions → toggle the extension off and on.');
|
||
await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: nu + OFFSCREEN_PAUSE_MS });
|
||
return;
|
||
}
|
||
console.warn(`[BG] offscreen-dokumentet svarer ikke — erstatter det (forsoeg ${offscreenGenskabt + 1}/${MAX_OFFSCREEN_GENSKAB})`);
|
||
await chrome.storage.local.set({ offscreenGenskabt: offscreenGenskabt + 1 });
|
||
try { await chrome.offscreen.closeDocument(); } catch (e) {
|
||
console.warn('[BG] kunne ikke lukke det doede dokument:', e?.message || e);
|
||
return; // proev igen ved naeste hjerteslag
|
||
}
|
||
}
|
||
|
||
// Versionen foelger med i URL'en. Et offscreen-dokument har IKKE
|
||
// chrome.runtime.getManifest() — kaldet kaster "is not a function", saa dokumentet
|
||
// kunne aldrig oplyse sin version, `offscreenSvarer()` sammenlignede null mod vores
|
||
// og fik altid falsk, og broen blev revet ned tre gange hvert tiende minut for evigt.
|
||
// (Symptomet var "Offscreen document closed before fully loading" — nedrivningen
|
||
// ramte dokumentet mens det stadig startede op.)
|
||
// URL'en er baaret af dokumentet selv: et dokument oprettet af en aeldre udgave
|
||
// baerer den aeldre version, hvilket er praecis den skelnen tjekket skal bruge.
|
||
let minVersion = '';
|
||
try { minVersion = chrome.runtime.getManifest().version; } catch {}
|
||
// Portomraadet foelger samme vej som versionen. MAALT 12/9 af Astra: serveren sender kun til den NYESTE forbundne
|
||
// udvidelse, saa en testbrowser paa de samme porte bliver "nyeste" for hver eneste koerende chats server. Et
|
||
// omraade i chrome.storage.local lader én profil koere isoleret - og fordi det er en VAERDI og ikke en kodeaendring,
|
||
// er repoets filer byte-identiske, og udgivelsens kode-aftryk er uroert. (Mit foerste forslag, at flytte portene
|
||
// til manifestet, ville have fjernet dem fra aftrykket - Astra maalte at de ligger i offscreen.js, som hashes.)
|
||
const porteParam = await portOmraadeFraLager();
|
||
await chrome.offscreen.createDocument({
|
||
url: 'offscreen.html' + (minVersion ? '?v=' + encodeURIComponent(minVersion) : '')
|
||
+ (porteParam ? (minVersion ? '&' : '?') + 'porte=' + porteParam : ''),
|
||
reasons: ['WORKERS'],
|
||
justification: 'Maintain persistent WebSocket connection to local MCP server',
|
||
});
|
||
}
|
||
|
||
// ── Action Logging ─────────────────────────────────────────────────────────
|
||
|
||
const SENSITIVE = new Set(['get_cookies', 'get_local_storage', 'execute_script', 'extract_token']);
|
||
|
||
// MAALT 11/9 (Astra, efterproevet): her stod `params: JSON.stringify(params).slice(0, 200)`.
|
||
// De foerste 200 tegn af ALLE parametre — ogsaa vaerdien til fill (adgangskoder), cookie-
|
||
// vaerdier og adresser med login-tokens — laa i klartekst i chrome.storage.local. Popuppen
|
||
// viser kun tid, vaerktoej og session, saa parametrene tjente intet. De gemmes ikke laengere,
|
||
// og poster gemt af aeldre udgaver renses ved opdatering (rensHandlingslog i onInstalled).
|
||
async function logAction(port, method) {
|
||
const category = SENSITIVE.has(method) ? 'sensitive' : 'safe';
|
||
const session = sessions.get(port);
|
||
const entry = {
|
||
time: Date.now(),
|
||
method,
|
||
category,
|
||
session: session?.label || `Port ${port}`,
|
||
color: session?.color || 'grey',
|
||
};
|
||
try {
|
||
const { actionLog = [] } = await chrome.storage.local.get({ actionLog: [] });
|
||
// MAALT 11/9 af Astra (e2e-review): et logAction der koerte samtidig med rensHandlingslog i onInstalled, havde laest den
|
||
// gamle log foer oprydningen skrev - og skrev saa den gamle adgangskode tilbage. Hver skrivning renser derfor selv.
|
||
const renset = (Array.isArray(actionLog) ? actionLog : []).filter(Boolean).map(({ params, ...resten }) => resten);
|
||
renset.unshift(entry);
|
||
if (renset.length > 50) renset.length = 50;
|
||
await chrome.storage.local.set({ actionLog: renset });
|
||
} catch (e) {
|
||
console.warn('[BG] handlingslog kunne ikke skrives:', e?.message || e);
|
||
}
|
||
}
|
||
|
||
async function rensHandlingslog() {
|
||
try {
|
||
const { actionLog } = await chrome.storage.local.get({ actionLog: [] });
|
||
if (!Array.isArray(actionLog) || !actionLog.some((p) => p && 'params' in p)) return;
|
||
await chrome.storage.local.set({ actionLog: actionLog.map(({ params, ...resten }) => resten) });
|
||
} catch (e) {
|
||
console.warn('[BG] gammel handlingslog kunne ikke renses:', e?.message || e);
|
||
}
|
||
}
|
||
|
||
// ── Message Handler — receives commands from offscreen.js ──────────────────
|
||
|
||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||
if (msg.type === 'mcp_command') {
|
||
const port = msg.port;
|
||
logAction(port, msg.method);
|
||
// Restore sessions from storage (service worker may have restarted), then take over any
|
||
// session orphaned by an unclean disconnect so navigate→click keeps hitting the same tab.
|
||
restoreSessions()
|
||
.then(() => adoptOrphanedSession(port, msg.pid).catch(() => null)) // adoption is best-effort, never fatal
|
||
.then(() => {
|
||
// Stempl pid'en ÉT sted, foer dispatch. De fem nedstroems getSession(port)-kald
|
||
// arver den derfra, saa ingen af dem behoever at kende til pid-begrebet.
|
||
getSession(port, msg.pid);
|
||
dispatch(port, msg.method, msg.params)
|
||
.then(result => sendResponse(result))
|
||
.catch(err => sendResponse({ __error: err.message || String(err) }));
|
||
})
|
||
.catch(err => sendResponse({ __error: err.message || String(err) })); // else a storage-restore reject hangs the caller
|
||
return true; // async response
|
||
}
|
||
|
||
if (msg.type === 'session_disconnect') {
|
||
releaseSession(msg.port);
|
||
return;
|
||
}
|
||
|
||
if (msg.type === 'reconnect') {
|
||
// FEJL RETTET 19/8: der var ingen fangst her. Lykkedes closeDocument()
|
||
// men fejlede ensureOffscreen() — fx fordi dokumentet stadig var ved at
|
||
// lukke — blev afvisningen slugt, og extensionen stod tilbage UDEN
|
||
// offscreen-dokument. Ingen WebSocket, ingen genopretning, og kun en
|
||
// manuel genindlaesning kunne redde den.
|
||
(async () => {
|
||
try {
|
||
if (await chrome.offscreen.hasDocument()) {
|
||
await chrome.offscreen.closeDocument();
|
||
}
|
||
} catch (e) {
|
||
console.warn('[BG] kunne ikke lukke offscreen:', e?.message || e);
|
||
}
|
||
// Proev at genskabe. Fejler det, tager hjerteslags-alarmen den
|
||
// inden for et minut — men kun fordi vi IKKE lader fejlen forsvinde.
|
||
try {
|
||
await ensureOffscreen();
|
||
} catch (e) {
|
||
console.error('[BG] kunne ikke genskabe offscreen:', e?.message || e);
|
||
setTimeout(() => ensureOffscreen().catch(console.error), 2000);
|
||
}
|
||
})();
|
||
return;
|
||
}
|
||
|
||
if (msg.type === 'ws_status') {
|
||
const count = msg.count || (msg.connected ? 1 : 0);
|
||
chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
|
||
if (count > 0) {
|
||
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
||
}
|
||
chrome.storage.local.set({
|
||
mcpConnected: msg.connected,
|
||
mcpCount: count,
|
||
mcpPorts: msg.ports || [],
|
||
});
|
||
return;
|
||
}
|
||
});
|
||
|
||
// ── OAuth Popup Interception ─────────────────────────────────────────────────
|
||
|
||
const OAUTH_DOMAINS = ['accounts.google.com', 'login.microsoftonline.com', 'github.com/login/oauth', 'slack.com/oauth', 'app.hubspot.com/oauth'];
|
||
|
||
// MAALT 10/9, reproduceret i selen: her stod kun `lastCreatedTabId = tab.id` — sat paa
|
||
// HVER onCreated, ogsaa naar brugeren selv trykker Cmd+T eller aabner sin netbank.
|
||
// `get_new_tab` adopterede den saa ind i agentens session, hvorefter skaermbillede,
|
||
// sidetekst og localStorage af BRUGERENS fane var lovligt. Reproduktionen: bruger aabner
|
||
// brugerens-netbank.example -> get_new_tab svarer med den -> fanen staar i tabIds.
|
||
//
|
||
// En ny fane hoerer til en session naar den er aabnet FRA en af dens faner (klik paa et
|
||
// link med target=_blank, en OAuth-popup). Det staar i openerTabId. Er der ingen opener,
|
||
// var det brugeren, og saa er den ikke vores.
|
||
let lastCreatedTabId = null;
|
||
// Aabneren huskes PR. FANE. En global "seneste aabner" blev 10/9 (Astra, reproduceret) laant af
|
||
// den forkerte fane, naar to faner blev aabnet mens get_new_tab ventede paa tabs.get.
|
||
const openerForFane = new Map();
|
||
// MAALT 11/9 af Astra (R5 F9), reproduceret: fane 1 aabner en popup og lukkes derefter (typisk
|
||
// "log ind i nyt vindue"). get_new_tab slog aabneren op i sessionens faner NU, hvor 1 var vaek,
|
||
// og svarede not-ours paa sessionens egen popup. Ejeren huskes derfor i det oejeblik fanen
|
||
// oprettes, hvor aabneren stadig staar i sin session.
|
||
const ejerForFane = new Map();
|
||
|
||
chrome.tabs.onCreated.addListener(async (tab) => {
|
||
lastCreatedTabId = tab.id;
|
||
openerForFane.set(tab.id, tab.openerTabId ?? null);
|
||
if (openerForFane.size > 500) openerForFane.delete(openerForFane.keys().next().value);
|
||
let ejer = null;
|
||
if (tab.openerTabId != null) {
|
||
for (const [p, s] of sessions) { if (s.tabIds.has(tab.openerTabId)) { ejer = p; break; } }
|
||
}
|
||
ejerForFane.set(tab.id, ejer);
|
||
if (ejerForFane.size > 500) ejerForFane.delete(ejerForFane.keys().next().value);
|
||
|
||
// Auto-claim OAuth popups for the session that opened them
|
||
if (tab.pendingUrl || tab.url) {
|
||
const url = tab.pendingUrl || tab.url;
|
||
const isOAuth = OAUTH_DOMAINS.some(d => url.includes(d));
|
||
if (isOAuth) {
|
||
for (const [port, session] of sessions) {
|
||
if (tab.openerTabId && session.tabIds.has(tab.openerTabId)) {
|
||
await addTabToSession(port, tab.id);
|
||
session.activeTabId = tab.id;
|
||
persistSessions();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// ── Deep Shadow DOM Query ────────────────────────────────────────────────────
|
||
// querySelectorDeep: finds elements inside shadow DOMs (Shopify, Salesforce, etc.)
|
||
|
||
function buildDeepQueryJS(selector) {
|
||
return `(function() {
|
||
function queryDeep(root, sel) {
|
||
const el = root.querySelector(sel);
|
||
if (el) return el;
|
||
for (const node of root.querySelectorAll('*')) {
|
||
if (node.shadowRoot) {
|
||
const found = queryDeep(node.shadowRoot, sel);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
return queryDeep(document, ${JSON.stringify(selector)});
|
||
})()`;
|
||
}
|
||
|
||
// ── Date Input Helpers ──────────────────────────────────────────────────────
|
||
|
||
const MONTHS_EN = ['january','february','march','april','may','june','july','august','september','october','november','december'];
|
||
const MONTHS_DA = ['januar','februar','marts','april','maj','juni','juli','august','september','oktober','november','december'];
|
||
const MONTHS_ABBR_EN = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||
|
||
function parsePlaceholderFormat(placeholder) {
|
||
if (!placeholder) return null;
|
||
const upper = placeholder.toUpperCase();
|
||
let sep = null;
|
||
if (upper.includes('/')) sep = '/';
|
||
else if (upper.includes('-')) sep = '-';
|
||
else if (upper.includes('.')) sep = '.';
|
||
else return null;
|
||
const parts = upper.split(sep);
|
||
if (parts.length !== 3) return null;
|
||
const order = parts.map(p => p.includes('Y') ? 'Y' : p.includes('M') ? 'M' : p.includes('D') ? 'D' : null);
|
||
if (order.includes(null) || new Set(order).size !== 3) return null;
|
||
const padded = parts.map(p => p.length >= 2);
|
||
// Fjerde runde: YY og YYYY skal kunne skelnes, ellers skrives "2026" i et felt der kun tager to cifre.
|
||
const lengths = parts.map(p => p.length);
|
||
return { sep, order, padded, lengths };
|
||
}
|
||
|
||
function isoToFormat(iso, fmt) {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||
if (!m) throw new Error('Invalid ISO date: ' + iso);
|
||
const [, y, mo, d] = m;
|
||
return fmt.order.map((slot, i) => {
|
||
if (slot === 'Y') return fmt.lengths && fmt.lengths[i] === 2 ? y.slice(2) : y;
|
||
if (slot === 'M') return fmt.padded[i] ? mo : String(parseInt(mo, 10));
|
||
if (slot === 'D') return fmt.padded[i] ? d : String(parseInt(d, 10));
|
||
}).join(fmt.sep);
|
||
}
|
||
|
||
function parseMonthYearText(text) {
|
||
if (!text) return null;
|
||
const cleaned = text.toLowerCase().trim();
|
||
const tables = [MONTHS_EN, MONTHS_DA, MONTHS_ABBR_EN];
|
||
for (const table of tables) {
|
||
for (let i = 0; i < table.length; i++) {
|
||
if (cleaned.includes(table[i])) {
|
||
const ym = cleaned.match(/(\d{4})/);
|
||
if (ym) return { year: parseInt(ym[1], 10), month: i + 1 };
|
||
}
|
||
}
|
||
}
|
||
const num = cleaned.match(/(\d{1,2})[\/\-\s.](\d{4})/);
|
||
if (num) return { year: parseInt(num[2], 10), month: parseInt(num[1], 10) };
|
||
return null;
|
||
}
|
||
|
||
function valueLooksLikeIso(raaVaerdi, iso, fmt) {
|
||
if (!raaVaerdi || !iso) return false;
|
||
const [y, m, d] = iso.split('-');
|
||
const Y = Number(y), M = Number(m), D = Number(d);
|
||
// Fjerde runde: en aflaesning der BEGYNDER med den oenskede ISO-dato (fx "2026-01-02T12:00:00") er den dato,
|
||
// uanset hvilket format placeholderen lover - ellers blev en korrekt dato afvist og kalenderen proevet oveni.
|
||
if (raaVaerdi.trim().startsWith(iso) && !/\d/.test(raaVaerdi.trim().charAt(iso.length))) return true;
|
||
// MAALT 11/9 af Astra (R5): klokkeslaet og tidszone er ikke en del af datoen. F2: "02/01 20:26" gav timen 20
|
||
// som aaret 2020 og ok:true. F3: "02/01/2026 12:00 GMT" blev afvist af bogstavkontrollen paa "GMT", og
|
||
// kalenderen blev proevet oveni. Begge fjernes foer datoen laeses.
|
||
const value = raaVaerdi
|
||
.replace(/\b\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?(\s*[ap]\.?m\.?)?(?![\d:])/gi, ' ')
|
||
.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, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
if (!value) return false;
|
||
// Astra, anden runde: tre `value.includes(...)` hver for sig godkendte "20/12/2026" som 2026-01-02.
|
||
// Tredje runde: tre `digits.includes(...)` koerte stadig FOER kontrollen af hele tal, saa
|
||
// "2026-1-1 02:00" blev 2026-11-02. Nu sammenlignes cifre som én streng KUN naar vaerdien udelukkende
|
||
// ER otte cifre - og med kendt format kun i formatets raekkefoelge ("01/12/2026" under DD/MM/YYYY er
|
||
// 1. december, ikke 12. januar).
|
||
const ordener = fmt?.order ? [fmt.order] : [['Y', 'M', 'D'], ['D', 'M', 'Y'], ['M', 'D', 'Y']];
|
||
const del = { Y: y, M: m, D: d };
|
||
if (/^\s*\d{8}\s*$/.test(value)) {
|
||
const cifre = value.trim();
|
||
return ordener.some((o) => o.map((slot) => del[slot]).join('') === cifre);
|
||
}
|
||
// Hele tal fra vaerdiens START - et klokkeslaet bagefter maa ikke levere dag eller maaned.
|
||
const tok = value.match(/\d+/g) || [];
|
||
const passer = (slot, s) => {
|
||
if (s === undefined) return false;
|
||
if (slot === 'Y') return (s.length === 4 && Number(s) === Y) || (s.length === 2 && Number(s) === Y % 100);
|
||
return s.length <= 2 && Number(s) === (slot === 'M' ? M : D);
|
||
};
|
||
// Fjerde runde: rene tal kun naar vaerdien ingen bogstaver har - i "2 Jan 26 05:00" blev timen ellers et aarstal.
|
||
if (!/\p{L}/u.test(value) && ordener.some((o) => o.every((slot, i) => passer(slot, tok[i])))) return true;
|
||
// Maanedsnavn ("2 Jan 2026", "2. maj 2026", "Jan 2, 2026"): dagen skal staa lige foer eller lige efter navnet.
|
||
const navne = [/jan/, /feb/, /mar/, /apr/, /ma[iyj]/, /jun/, /jul/, /aug/, /sep/, /o[ck]t/, /nov/, /de[cz]/];
|
||
const navn = navne[M - 1];
|
||
if (navn) {
|
||
const lav = value.toLowerCase();
|
||
const n = navn.source;
|
||
const foer = new RegExp('^\\s*(\\d{1,2})\\.?\\s+' + n + '[a-zæøå]*\\.?,?\\s+(\\d{4})(?!\\d)').exec(lav);
|
||
const efter = new RegExp('^\\s*' + n + '[a-zæøå]*\\.?\\s+(\\d{1,2}),?\\s+(\\d{4})(?!\\d)').exec(lav);
|
||
for (const r of [foer, efter]) if (r && Number(r[1]) === D && Number(r[2]) === Y) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function getDateInputInfo(tabId, selector) {
|
||
const json = await debuggerEval(tabId, `(() => {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
if (!el) return JSON.stringify({ found: false });
|
||
return JSON.stringify({
|
||
found: true,
|
||
tag: el.tagName,
|
||
inputType: (el.type || '').toLowerCase(),
|
||
readOnly: !!el.readOnly,
|
||
disabled: !!el.disabled,
|
||
placeholder: el.placeholder || '',
|
||
ariaLabel: el.getAttribute('aria-label') || '',
|
||
value: el.value !== undefined ? el.value : (el.textContent || ''),
|
||
});
|
||
})()`);
|
||
return JSON.parse(json);
|
||
}
|
||
|
||
async function readBackValue(tabId, selector) {
|
||
const json = await debuggerEval(tabId, `(() => {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
if (!el) return JSON.stringify({ value: null });
|
||
return JSON.stringify({ value: el.value !== undefined ? el.value : (el.textContent || '') });
|
||
})()`);
|
||
return JSON.parse(json).value;
|
||
}
|
||
|
||
async function setDateNative(tabId, selector, iso) {
|
||
const r = await safeExecuteScript(tabId, (sel, val) => {
|
||
const el = document.querySelector(sel);
|
||
if (!el) return { ok: false, error: 'not-found' };
|
||
try {
|
||
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||
el.focus();
|
||
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||
if (setter) setter.call(el, val); else el.value = val;
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
el.blur();
|
||
return { ok: true, value: el.value };
|
||
} catch (e) {
|
||
return { ok: false, error: e.message };
|
||
}
|
||
}, [selector, iso]);
|
||
if (r.cspBlocked) {
|
||
await debuggerEval(tabId, `(() => {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
if (!el) return;
|
||
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||
if (setter) setter.call(el, ${JSON.stringify(iso)}); else el.value = ${JSON.stringify(iso)};
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
el.blur();
|
||
})()`);
|
||
return { ok: true, csp: true };
|
||
}
|
||
return r.result || { ok: false, error: 'no-result' };
|
||
}
|
||
|
||
async function setDateMaskedTyping(tabId, selector, iso, format) {
|
||
const formatted = isoToFormat(iso, format);
|
||
await debuggerFocus(tabId, selector);
|
||
await debuggerAttach(tabId);
|
||
try {
|
||
await clearFieldAttached(tabId);
|
||
await cdpSend(tabId, 'Input.insertText', { text: formatted });
|
||
await tastParAttached(tabId, { key: 'Tab', code: 'Tab' }, { key: 'Tab', code: 'Tab' });
|
||
} finally {
|
||
await debuggerDetach(tabId);
|
||
}
|
||
return { ok: true, formatted };
|
||
}
|
||
|
||
const PICKER_OPEN_SELECTORS = [
|
||
'[role="dialog"] [role="grid"]',
|
||
'[role="dialog"] [role="gridcell"]',
|
||
'.react-datepicker',
|
||
'.MuiPickersPopper-root',
|
||
'.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)',
|
||
'[class*="DayPicker"]:not(input)',
|
||
'[class*="Calendar"][class*="open" i]',
|
||
];
|
||
|
||
async function isPickerOpen(tabId) {
|
||
return await debuggerEval(tabId, `(() => {
|
||
const sels = ${JSON.stringify(PICKER_OPEN_SELECTORS)};
|
||
for (const s of sels) {
|
||
try { if (document.querySelector(s)) return true; } catch {}
|
||
}
|
||
return false;
|
||
})()`);
|
||
}
|
||
|
||
async function setDatePicker(tabId, selector, iso) {
|
||
const [yStr, mStr, dStr] = iso.split('-');
|
||
const targetYear = parseInt(yStr, 10);
|
||
const targetMonth = parseInt(mStr, 10);
|
||
const targetDay = parseInt(dStr, 10);
|
||
|
||
const inputEl = await resolveElement(tabId, selector);
|
||
if (!inputEl) return { ok: false, error: 'input-not-found' };
|
||
await debuggerClick(tabId, inputEl.x, inputEl.y);
|
||
|
||
let opened = false;
|
||
for (let i = 0; i < 20; i++) {
|
||
await new Promise(r => setTimeout(r, 100));
|
||
if (await isPickerOpen(tabId)) { opened = true; break; }
|
||
}
|
||
|
||
if (!opened) {
|
||
const triggerClicked = await safeExecuteScript(tabId, (sel) => {
|
||
const el = document.querySelector(sel);
|
||
if (!el) return false;
|
||
const candidates = [
|
||
...(el.parentElement?.querySelectorAll('button, [role="button"], [aria-haspopup]') || []),
|
||
...(el.parentElement?.parentElement?.querySelectorAll('button, [role="button"], [aria-haspopup]') || []),
|
||
];
|
||
for (const c of candidates) {
|
||
const label = (c.getAttribute('aria-label') || '').toLowerCase();
|
||
if (label.includes('calendar') || label.includes('date') || label.includes('vælg dato') || label.includes('open') || label.includes('åbn')) {
|
||
c.click();
|
||
return true;
|
||
}
|
||
}
|
||
for (const c of candidates) {
|
||
if (c.querySelector('svg, [class*="calendar" i]')) {
|
||
c.click();
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}, [selector]);
|
||
if (triggerClicked.result) {
|
||
for (let i = 0; i < 15; i++) {
|
||
await new Promise(r => setTimeout(r, 100));
|
||
if (await isPickerOpen(tabId)) { opened = true; break; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// Naar filvaelgeren aldrig aabnede, er det ene af to: siden aabnede den slet ikke, eller
|
||
// den aabnede som OS-dialog uden for siden. Det andet kan ingen browser-udvidelse naa - CDP
|
||
// ser kun det der sker i fanen. Det er den ENE af vores 26 vaegge der aerligt ligger uden for
|
||
// browseren, og derfor det ene sted en henvisning til et skrivebords-vaerktoej er sand.
|
||
if (!opened) return { ok: false, error: 'picker-did-not-open',
|
||
note: 'The file picker never opened in the page. Either the trigger does not open one, ' +
|
||
'or it opened as an operating-system dialog outside the page - which no browser ' +
|
||
'extension can reach, this one included. If you have desktop-level tools in this ' +
|
||
'session (an OS automation MCP server such as computer-mcp), that dialog is theirs ' +
|
||
'to drive. Otherwise ask the user to pick the file.' };
|
||
|
||
const MAX_NAV = 36;
|
||
let navAttempts = 0;
|
||
let lastHeader = null;
|
||
let stuck = 0;
|
||
let navExitReason = 'reached-target';
|
||
let lastReachedMonthYear = null;
|
||
for (let i = 0; i < MAX_NAV; i++) {
|
||
const headerJson = await debuggerEval(tabId, `(() => {
|
||
const roots = [
|
||
document.querySelector('[role="dialog"]'),
|
||
document.querySelector('.react-datepicker'),
|
||
document.querySelector('.MuiPickersPopper-root'),
|
||
document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)'),
|
||
].filter(Boolean);
|
||
for (const root of roots) {
|
||
const candidates = [
|
||
root.querySelector('[role="heading"]'),
|
||
root.querySelector('[aria-live]'),
|
||
root.querySelector('.MuiPickersCalendarHeader-label'),
|
||
root.querySelector('.react-datepicker__current-month'),
|
||
root.querySelector('.ant-picker-header-view'),
|
||
].filter(Boolean);
|
||
for (const el of candidates) {
|
||
const t = (el.textContent || '').trim();
|
||
if (t.length > 0 && t.length < 80) return JSON.stringify({ text: t });
|
||
}
|
||
}
|
||
return JSON.stringify({});
|
||
})()`);
|
||
const header = JSON.parse(headerJson);
|
||
const parsed = parseMonthYearText(header.text || '');
|
||
if (!parsed) {
|
||
navExitReason = header.text ? 'header-parse-failed' : 'no-header-found';
|
||
break;
|
||
}
|
||
lastReachedMonthYear = `${parsed.year}-${String(parsed.month).padStart(2, '0')}`;
|
||
|
||
if (header.text === lastHeader) {
|
||
stuck++;
|
||
if (stuck >= 3) { navExitReason = 'navigation-stuck'; break; }
|
||
} else {
|
||
stuck = 0;
|
||
lastHeader = header.text;
|
||
}
|
||
|
||
const delta = (targetYear * 12 + targetMonth) - (parsed.year * 12 + parsed.month);
|
||
if (delta === 0) break;
|
||
if (i === MAX_NAV - 1) {
|
||
navExitReason = 'max-nav-exceeded';
|
||
}
|
||
|
||
const dir = delta > 0 ? 'next' : 'prev';
|
||
const navClicked = await safeExecuteScript(tabId, (direction) => {
|
||
const roots = [
|
||
document.querySelector('[role="dialog"]'),
|
||
document.querySelector('.react-datepicker'),
|
||
document.querySelector('.MuiPickersPopper-root'),
|
||
document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)'),
|
||
].filter(Boolean);
|
||
const labels = direction === 'next'
|
||
? ['next month', 'next', 'forward', 'næste']
|
||
: ['previous month', 'previous', 'prev', 'back', 'forrige'];
|
||
const classFallbacks = direction === 'next'
|
||
? ['.react-datepicker__navigation--next', '.ant-picker-header-next-btn', '.ant-picker-header-super-next-btn']
|
||
: ['.react-datepicker__navigation--previous', '.ant-picker-header-prev-btn', '.ant-picker-header-super-prev-btn'];
|
||
for (const root of roots) {
|
||
const buttons = [...root.querySelectorAll('button, [role="button"]')];
|
||
for (const b of buttons) {
|
||
const label = (b.getAttribute('aria-label') || b.title || '').toLowerCase();
|
||
if (labels.some(l => label.includes(l))) { b.click(); return true; }
|
||
}
|
||
for (const cs of classFallbacks) {
|
||
const b = root.querySelector(cs);
|
||
if (b) { b.click(); return true; }
|
||
}
|
||
}
|
||
return false;
|
||
}, [dir]);
|
||
|
||
if (!navClicked.result) {
|
||
await debuggerAttach(tabId);
|
||
try {
|
||
const key = delta > 0 ? 'PageDown' : 'PageUp';
|
||
await tastParAttached(tabId, { key, code: key }, { key, code: key });
|
||
} finally {
|
||
await debuggerDetach(tabId);
|
||
}
|
||
}
|
||
navAttempts++;
|
||
await new Promise(r => setTimeout(r, 90));
|
||
}
|
||
|
||
const dayResult = await safeExecuteScript(tabId, (day, year, month, monthsEn, monthsDa, monthsAbbr) => {
|
||
const roots = [
|
||
document.querySelector('[role="dialog"]'),
|
||
document.querySelector('.react-datepicker'),
|
||
document.querySelector('.MuiPickersPopper-root'),
|
||
document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)'),
|
||
].filter(Boolean);
|
||
const monthEn = monthsEn[month - 1];
|
||
const monthDa = monthsDa[month - 1];
|
||
const monthAbbr = monthsAbbr[month - 1];
|
||
|
||
for (const root of roots) {
|
||
const cells = [...root.querySelectorAll('[role="gridcell"], .react-datepicker__day, .ant-picker-cell, [class*="PickersDay"]')];
|
||
const isDisabled = (c) => c.getAttribute('aria-disabled') === 'true' ||
|
||
c.classList.contains('disabled') ||
|
||
c.classList.contains('react-datepicker__day--disabled') ||
|
||
c.classList.contains('ant-picker-cell-disabled') ||
|
||
c.classList.contains('Mui-disabled');
|
||
const isOutside = (c) => {
|
||
const cls = c.className || '';
|
||
if (/outside|other-month|--prev|--next|adjacent/i.test(cls)) return true;
|
||
if (c.classList.contains('react-datepicker__day--outside-month')) return true;
|
||
if (c.classList.contains('ant-picker-cell') && !c.classList.contains('ant-picker-cell-in-view')) return true;
|
||
return false;
|
||
};
|
||
|
||
for (const c of cells) {
|
||
if (isDisabled(c) || isOutside(c)) continue;
|
||
const label = (c.getAttribute('aria-label') || '').toLowerCase();
|
||
if (!label) continue;
|
||
const matchesMonth = label.includes(monthEn) || label.includes(monthDa) || label.includes(monthAbbr);
|
||
const matchesYear = label.includes(String(year));
|
||
const dayPattern = new RegExp('\\b' + day + '(st|nd|rd|th)?\\b');
|
||
const dayPaddedPattern = new RegExp('\\b' + String(day).padStart(2, '0') + '\\b');
|
||
if (matchesMonth && matchesYear && (dayPattern.test(label) || dayPaddedPattern.test(label))) {
|
||
c.click();
|
||
return { ok: true, method: 'aria-label', label };
|
||
}
|
||
}
|
||
|
||
for (const c of cells) {
|
||
if (isDisabled(c) || isOutside(c)) continue;
|
||
const text = (c.textContent || '').trim();
|
||
if (text === String(day) || text === String(day).padStart(2, '0')) {
|
||
c.click();
|
||
return { ok: true, method: 'text-content' };
|
||
}
|
||
}
|
||
}
|
||
return { ok: false, error: 'day-not-found' };
|
||
}, [targetDay, targetYear, targetMonth, MONTHS_EN, MONTHS_DA, MONTHS_ABBR_EN]);
|
||
|
||
if (!dayResult.result || !dayResult.result.ok) {
|
||
return {
|
||
ok: false,
|
||
error: dayResult.result?.error || 'day-click-failed',
|
||
navAttempts,
|
||
navExitReason,
|
||
lastReachedMonthYear,
|
||
targetMonthYear: `${targetYear}-${String(targetMonth).padStart(2, '0')}`,
|
||
};
|
||
}
|
||
|
||
await new Promise(r => setTimeout(r, 350));
|
||
return { ok: true, method: dayResult.result.method, navAttempts };
|
||
}
|
||
|
||
async function collectVisibleErrors(tabId, selector) {
|
||
const json = await debuggerEval(tabId, `(() => {
|
||
const errs = [];
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
if (el?.getAttribute('aria-invalid') === 'true') errs.push('aria-invalid=true on input');
|
||
const candidates = [
|
||
...document.querySelectorAll('[role="alert"], .error-text, [class*="error" i]:not(input):not(button)'),
|
||
].slice(0, 8);
|
||
for (const c of candidates) {
|
||
const t = (c.textContent || '').trim();
|
||
if (t && t.length < 200 && c.offsetHeight > 0) errs.push(t);
|
||
}
|
||
return JSON.stringify(errs);
|
||
})()`);
|
||
try { return JSON.parse(json); } catch { return []; }
|
||
}
|
||
|
||
// ── Overlay Dismissal Helper ────────────────────────────────────────────────
|
||
|
||
async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
|
||
// Clamp to sensible range; reject sloppy input
|
||
const passes = Math.max(1, Math.min(10, Number.isInteger(maxPasses) ? maxPasses : 3));
|
||
const allDismissed = [];
|
||
const allSkipped = [];
|
||
|
||
for (let pass = 0; pass < passes; pass++) {
|
||
const r = await safeExecuteScript(tabId, (s) => {
|
||
const dismissed = [];
|
||
const skipped = [];
|
||
|
||
// "Safe" texts cannot revert form data — they're purely informational close affordances
|
||
const safeTexts = [
|
||
"luk", "dismiss", "close", "got it", "got it, thanks",
|
||
"not now", "ikke nu", "senere", "later",
|
||
"don't show", "don't show again", "dont show again", "dont show",
|
||
"no thanks", "maybe later", "ok", "ok!", "okay",
|
||
];
|
||
// "Ambiguous" texts MAY revert partial form data ("Cancel" usually reverts state)
|
||
// — only used when overlay has no editable form fields, or in aggressive scope
|
||
// ── VETO-LISTE (MAALT 23/8 — det dyreste fund i hele auditten) ────────────
|
||
//
|
||
// Koert med den ORDRETTE kode mod knapper i en rigtig browser trykkede
|
||
// dismiss_overlays paa "Close account", "Close and delete everything",
|
||
// "Cancel subscription" og "Afvis betalingen permanent" — og paa enhver knap
|
||
// med aria-label="Close account" eller "Luk kontoen". Det skete i DEFAULT-scope,
|
||
// ikke kun aggressive.
|
||
//
|
||
// Aarsagen: "close" og "luk" er lovlige luk-ord, og matchningen havde ingen
|
||
// ord-graense. "Close account" indeholder "close". Og instruktionerne beder
|
||
// agenten kalde dismiss_overlays FOER hvert stoerre skridt, saa det ville ske
|
||
// paa hver eneste side hvor saadan en knap findes.
|
||
//
|
||
// Et luk-ord er derfor ikke laengere nok: findes ET af disse ord i teksten eller
|
||
// aria-labelen, klikkes der ALDRIG — uanset hvor godt resten matcher. Det er en
|
||
// veto, ikke en vaegtning. Et overlay der ikke bliver lukket koster et ekstra
|
||
// skridt; en lukket konto koster brugeren penge eller adgang.
|
||
const VETO = [
|
||
'account', 'konto', 'subscription', 'abonnement', 'membership', 'medlemskab',
|
||
'payment', 'betaling', 'kort', 'card', 'billing', 'faktura', 'invoice',
|
||
'delete', 'slet', 'remove', 'fjern', 'erase', 'wipe', 'destroy',
|
||
'permanent', 'permanently', 'forever', 'for evigt', 'irreversibl',
|
||
'unsubscribe', 'opsig', 'afmeld', 'terminate', 'opheav',
|
||
'deactivate', 'deaktiver', 'disable', 'deaktivér',
|
||
'sign out', 'log out', 'log ud', 'logout', 'sign-out',
|
||
'order', 'ordre', 'purchase', 'koeb', 'køb', 'refund', 'refunder',
|
||
];
|
||
const erFarlig = (tekst) => {
|
||
const t = (tekst || '').toLowerCase();
|
||
return VETO.some((v) => t.includes(v));
|
||
};
|
||
|
||
const ambiguousTexts = [
|
||
"skip", "cancel", "afvis", "spring over",
|
||
];
|
||
const xChars = ['×', '✕', '✖', '⨯'];
|
||
|
||
// MAALT 21/8: her stod `if (!el || !el.offsetParent && el.tagName !== 'BODY') return false`.
|
||
// offsetParent er ALTID null for et position:fixed-element — det er ikke en fejl i
|
||
// browseren, det er definitionen. Saa hele overlay-fjerneren var blind for praecis
|
||
// den slags elementer som cookie-bannere, samtykke-bjaelker og modaler ER. Et
|
||
// synligt fixed-banner med <button aria-label="Close"> blev hverken fundet som
|
||
// overlay eller som luk-knap: dismissed:[], skipped:[], count:0 — tavst intet.
|
||
//
|
||
// Rigtig synlighed laeses af layout og stil, ikke af offsetParent.
|
||
const isVisible = (el) => {
|
||
if (!el) return false;
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width <= 0 || rect.height <= 0) return false;
|
||
const st = getComputedStyle(el);
|
||
if (st.display === 'none' || st.visibility === 'hidden' || st.visibility === 'collapse') return false;
|
||
if (parseFloat(st.opacity) === 0) return false;
|
||
return true;
|
||
};
|
||
|
||
const findCloseAffordance = (overlay, allowAmbiguous) => {
|
||
const all = [...overlay.querySelectorAll('button, [role="button"], a[href="#"], [aria-label]')];
|
||
const allTexts = allowAmbiguous ? [...safeTexts, ...ambiguousTexts] : safeTexts;
|
||
|
||
// Priority 1: aria-label match. MAALT 23/8: "always safe" var FORKERT — aria-label
|
||
// "Close account" indeholder "close", saa knappen blev trykket. Vetoet nedenfor
|
||
// er derfor det foerste der koeres, foer nogen match overhovedet forsoeges.
|
||
for (const c of all) {
|
||
if (!isVisible(c)) continue;
|
||
const label = (c.getAttribute('aria-label') || '').toLowerCase();
|
||
if (!label) continue;
|
||
if (erFarlig(label) || erFarlig(c.textContent)) continue; // veto — se listen ovenfor
|
||
if (label.includes('close') || label.includes('dismiss') || label.includes('luk')) {
|
||
return { el: c, method: 'aria-label', label };
|
||
}
|
||
if (allowAmbiguous && label.includes('afvis')) {
|
||
return { el: c, method: 'aria-label', label };
|
||
}
|
||
}
|
||
|
||
// Priority 2: button text exact match
|
||
for (const c of all) {
|
||
if (!isVisible(c)) continue;
|
||
const text = (c.textContent || '').trim().toLowerCase();
|
||
if (!text || text.length > 30) continue;
|
||
if (erFarlig(text) || erFarlig(c.getAttribute('aria-label'))) continue; // veto
|
||
if (allTexts.some(t => text === t || text === t + '!' || text === t + '.')) {
|
||
return { el: c, method: 'text-exact', label: text };
|
||
}
|
||
}
|
||
// Priority 3: button text contains
|
||
for (const c of all) {
|
||
if (!isVisible(c)) continue;
|
||
const text = (c.textContent || '').trim().toLowerCase();
|
||
if (!text || text.length > 40) continue;
|
||
// MAALT 22/8: contains-passet gjorde "ok" til en delstreng-traeffer, saa
|
||
// "Book a demo", "Unlock account" og "Cookie settings" blev klikbare — i
|
||
// DEFAULT-scope. Korte ord maa kun matche eksakt (prioritet 2 ovenfor).
|
||
if (erFarlig(text) || erFarlig(c.getAttribute('aria-label'))) continue; // veto
|
||
if (allTexts.filter(t => t.length >= 5).some(t => text.includes(t))) {
|
||
return { el: c, method: 'text-contains', label: text };
|
||
}
|
||
}
|
||
|
||
// Priority 4: × character buttons (always safe — these are universal close)
|
||
for (const c of all) {
|
||
if (!isVisible(c)) continue;
|
||
const text = (c.textContent || '').trim();
|
||
if (erFarlig(c.getAttribute('aria-label'))) continue; // et × med farlig aria-label
|
||
if (xChars.includes(text)) {
|
||
return { el: c, method: 'x-char', label: text };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const overlays = new Set();
|
||
const selectors = [
|
||
'[role="dialog"]:not([aria-hidden="true"])',
|
||
'[role="alertdialog"]:not([aria-hidden="true"])',
|
||
'[role="tooltip"]:not([aria-hidden="true"])',
|
||
'[role="alert"]',
|
||
'[class*="modal" i]:not([class*="-hidden"]):not([style*="display: none"])',
|
||
'[class*="tooltip" i]:not([class*="-hidden"])',
|
||
'[class*="popover" i]:not([class*="-hidden"])',
|
||
'[class*="overlay" i]:not([class*="-hidden"])',
|
||
'[class*="banner" i]:not([class*="-hidden"]):not(input):not(button)',
|
||
'[data-testid*="dialog" i]',
|
||
'[data-testid*="modal" i]',
|
||
// MAALT 22/8 af flowtesten: listen matchede kun paa class, aldrig paa id. Et
|
||
// helt almindeligt <div id="banner"> blev derfor ALDRIG set som et overlay —
|
||
// og cookie-bannere skrives lige saa ofte med id som med class. Hullet var
|
||
// skjult indtil i dag, fordi det strukturelle fixed/sticky-spor fangede dem
|
||
// alligevel; det spor blev skaaret efter sikkerhedsreview, og saa stod hullet
|
||
// bart. De samme fem ord, samme regler — bare paa id.
|
||
//
|
||
// Det her er IKKE det skaarne spor i ny form: her har sideforfatteren selv
|
||
// kaldt elementet en dialog/modal/banner. Det er en eksplicit erklaering, ikke
|
||
// et gaet ud fra placering, saa faren ved delstrengs-matchning gaelder ikke.
|
||
'[id*="modal" i]:not(input):not(button)',
|
||
'[id*="overlay" i]:not(input):not(button)',
|
||
'[id*="popover" i]:not(input):not(button)',
|
||
'[id*="banner" i]:not(input):not(button)',
|
||
'[id*="dialog" i]:not(input):not(button)',
|
||
];
|
||
for (const sel of selectors) {
|
||
try {
|
||
for (const el of document.querySelectorAll(sel)) {
|
||
if (isVisible(el) && el.tagName !== 'INPUT' && el.tagName !== 'BUTTON') {
|
||
overlays.add(el);
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Et strukturelt spor (alt fixed/sticky over 40x20px som overlay-kandidat) blev
|
||
// proevet 21/8 og SKAARET 22/8 efter sikkerhedsreview: findCloseAffordance matcher
|
||
// paa delstrenge, saa "Cancel subscription", "Close account" og "Book now" (via "ok")
|
||
// alle blev klikkbare — paa hver eneste side, og instruktionerne beder agenten kalde
|
||
// dismiss_overlays foer hvert stoerre skridt. Den maalte fejl var offsetParent-
|
||
// blindheden i isVisible ovenfor; den er rettet. Sporet var ny adfaerd uden bevist
|
||
// behov. Genindfoeres kun med eksakt tekstmatch og et krav om luk-affordance som
|
||
// direkte barn.
|
||
|
||
for (const overlay of overlays) {
|
||
const role = overlay.getAttribute('role') || (overlay.className || '').split(' ')[0] || 'unknown';
|
||
|
||
// Inspect for editable form fields
|
||
const editableTextInputs = overlay.querySelectorAll(
|
||
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="checkbox"]):not([type="radio"]):not([readonly]):not([disabled]), textarea:not([readonly]):not([disabled]), [contenteditable="true"]'
|
||
);
|
||
const allEditableInputs = overlay.querySelectorAll(
|
||
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([readonly]):not([disabled]), textarea:not([readonly]):not([disabled]), [contenteditable="true"]'
|
||
);
|
||
const hasTextFields = editableTextInputs.length > 0;
|
||
const hasOnlyCheckboxRadios = !hasTextFields && allEditableInputs.length > 0;
|
||
|
||
// Determine if ambiguous keywords (Skip/Cancel/Afvis) are allowed
|
||
let allowAmbiguous;
|
||
if (s === 'aggressive') {
|
||
allowAmbiguous = true;
|
||
} else if (role === 'tooltip' || role === 'alert') {
|
||
allowAmbiguous = true; // tooltips never hold form data
|
||
} else if (hasTextFields) {
|
||
allowAmbiguous = false; // protect form data — only safe keywords
|
||
} else {
|
||
allowAmbiguous = true; // checkbox-only or empty dialogs — fair game
|
||
}
|
||
|
||
const found = findCloseAffordance(overlay, allowAmbiguous);
|
||
if (found) {
|
||
try {
|
||
found.el.click();
|
||
dismissed.push({ role, method: found.method, label: found.label, scope: allowAmbiguous ? 'ambiguous-ok' : 'safe-only' });
|
||
} catch (e) {
|
||
skipped.push({ role, reason: 'click-error', error: e.message });
|
||
}
|
||
} else {
|
||
skipped.push({
|
||
role,
|
||
reason: hasTextFields && !allowAmbiguous
|
||
? 'no-safe-dismiss-affordance (text fields present)'
|
||
: 'no-dismiss-affordance-found',
|
||
hasTextFields,
|
||
hasOnlyCheckboxRadios,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { dismissed, skipped };
|
||
}, [scope]);
|
||
|
||
const passResult = r.result || { dismissed: [], skipped: [] };
|
||
if (pass === 0) allSkipped.push(...passResult.skipped);
|
||
if (passResult.dismissed.length === 0) break;
|
||
allDismissed.push(...passResult.dismissed);
|
||
await new Promise(r2 => setTimeout(r2, 250));
|
||
}
|
||
|
||
return { dismissed: allDismissed, skipped: allSkipped };
|
||
}
|
||
|
||
// ── Combobox / Autocomplete Helper ──────────────────────────────────────────
|
||
|
||
async function setCombobox(tabId, selector, values, opts = {}) {
|
||
const valueList = Array.isArray(values) ? values : [values];
|
||
const multi = !!opts.multi;
|
||
const queryPrefixLen = opts.query_chars || 4;
|
||
const waitMs = opts.wait_ms || 3000;
|
||
const waitIterations = Math.max(1, Math.ceil(waitMs / 100));
|
||
const results = [];
|
||
|
||
// MAALT 9/9-2026: paa en aegte <select> brugte den her 8,5 sekunder paa at sige nej.
|
||
// Den klikkede feltet, forsoegte at tomme det med en input-vaerdisaetter (en <select>
|
||
// ER ikke et input), skrev tekst ind, og pollede saa 30 gange efter en listbox der
|
||
// aldrig kan opstaa — for saa at svare "no-options-rendered". Kapaciteten fandtes hele
|
||
// tiden i browser_select_option, som klarer samme felt paa 9 ms. Nu siger den det
|
||
// med det samme i stedet for at lade agenten vente og gaette.
|
||
const erNativeSelect = await debuggerEval(tabId, `(() => {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
return el?.tagName === 'SELECT';
|
||
})()`).catch(() => false);
|
||
if (erNativeSelect) {
|
||
return {
|
||
ok: false,
|
||
error: 'native-select',
|
||
hint: 'The field is a plain <select>. Use browser_select_option instead - ' +
|
||
'set_combobox is for dropdowns built from div/li with a listbox.',
|
||
selector,
|
||
};
|
||
}
|
||
|
||
for (const val of valueList) {
|
||
try {
|
||
const inputEl = await resolveElement(tabId, selector);
|
||
if (!inputEl) {
|
||
results.push({ value: val, ok: false, error: 'input-not-found' });
|
||
continue;
|
||
}
|
||
// FUNDET 13/9 af BAADE Astra og Fable, uafhaengigt: her stod `await debuggerClick(...)`
|
||
// og svaret blev kastet vaek. `klikLandede()` er den faelles regel som click, click_xy
|
||
// og select_option allerede bruger - set_combobox var det ene sted der ikke spurgte.
|
||
// Landede klikket ikke, gik soegeteksten i det felt der HAVDE fokus, og vi ventede
|
||
// wait_ms for saa at sige 'no-options-rendered'. En bivirkning i et fremmed felt,
|
||
// meldt som sidens skyld. Et UVIST klik stopper os ikke - kun et maalt nej.
|
||
const aabneKlik = await debuggerClick(tabId, inputEl.x, inputEl.y);
|
||
if (aabneKlik && aabneKlik.landed === false && !klikLandede(aabneKlik)) {
|
||
results.push({ value: val, ok: false, error: 'click-did-not-open-list',
|
||
note: 'The click meant to open the dropdown never reached the page, so the list cannot ' +
|
||
'appear. Something may be covering the field (cookie banner, overlay), or the tab ' +
|
||
'is in the background, where Chrome does not deliver mouse input. Call browser_dismiss_overlays ' +
|
||
'or browser_switch_tab and try again.' });
|
||
continue;
|
||
}
|
||
await new Promise(r => setTimeout(r, 120));
|
||
|
||
// Clear input only if non-empty. Backspace on empty multi-select deletes the previous chip
|
||
// (react-select, MUI Autocomplete, Meta combobox all behave this way) — so we use native
|
||
// value-setter to clear cleanly without ever pressing Backspace on an empty field.
|
||
const currentValue = await readBackValue(tabId, selector);
|
||
if (currentValue) {
|
||
await safeExecuteScript(tabId, (sel) => {
|
||
const el = document.querySelector(sel);
|
||
if (!el) return;
|
||
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||
if (setter) setter.call(el, ''); else el.value = '';
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, [selector]);
|
||
}
|
||
|
||
// Type partial query via Input.insertText (bypasses per-keystroke validators)
|
||
const query = val.slice(0, Math.min(queryPrefixLen, val.length));
|
||
await debuggerAttach(tabId);
|
||
await cdpSend(tabId, 'Input.insertText', { text: query });
|
||
|
||
// FUNDET 13/9 af Astra: naar listen ikke kom, svarede vi 'no-options-rendered' - som
|
||
// peger paa siden. Men vejen hertil gaar gennem to CDP-kommandoer der KVITTERER uden
|
||
// at love levering (klikket der aabner feltet, og selve skrivningen). Landede de ikke,
|
||
// er det ikke siden der mangler muligheder.
|
||
//
|
||
// Vi kan ikke maale klikket uden at bygge et bevis til. Feltet KAN vi laese. Og kun paa
|
||
// et rigtigt inputfelt: peger vaelgeren paa en indpakning, er en tom textContent intet
|
||
// bevis, saa den sag forbliver unknown og gaar den lange vej som foer.
|
||
const efterSkrift = await debuggerEval(tabId, `(() => {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
if (!el || !('value' in el)) return null;
|
||
return el.value;
|
||
})()`).catch(() => null);
|
||
if (efterSkrift === '') {
|
||
results.push({ value: val, ok: false, error: 'search-text-not-delivered', query,
|
||
note: 'The field was empty after the search text was sent, so it never arrived. ' +
|
||
'The page is not missing options - it was never asked. The tab may be in the ' +
|
||
'background, where Chrome does not deliver mouse or keyboard input. Call browser_switch_tab ' +
|
||
'and try again.' });
|
||
continue;
|
||
}
|
||
|
||
// Wait for listbox/options to appear
|
||
let ready = false;
|
||
for (let i = 0; i < waitIterations; i++) {
|
||
await new Promise(r => setTimeout(r, 100));
|
||
const found = await debuggerEval(tabId, `(() => {
|
||
const lbs = document.querySelectorAll('[role="listbox"], [role="grid"][aria-label*="suggest" i], [class*="autocomplete" i] [class*="option" i], [class*="menu" i][role]:not([aria-hidden="true"])');
|
||
for (const lb of lbs) {
|
||
if (lb.offsetHeight === 0) continue;
|
||
const opts = lb.querySelectorAll('[role="option"], [role="menuitem"], [data-option-index], [class*="option" i]:not([class*="optgroup" i])');
|
||
if (opts.length > 0) return true;
|
||
}
|
||
return false;
|
||
})()`);
|
||
if (found) { ready = true; break; }
|
||
}
|
||
|
||
if (!ready) {
|
||
results.push({ value: val, ok: false, error: 'no-options-rendered', query, waitMs });
|
||
continue;
|
||
}
|
||
|
||
// Find and click matching option
|
||
const click = await safeExecuteScript(tabId, (query) => {
|
||
const lbs = [...document.querySelectorAll('[role="listbox"], [role="grid"][aria-label*="suggest" i], [class*="autocomplete" i], [class*="menu" i][role]:not([aria-hidden="true"])')]
|
||
.filter(lb => lb.offsetHeight > 0);
|
||
|
||
const queryLower = query.toLowerCase();
|
||
const allOptions = [];
|
||
for (const lb of lbs) {
|
||
const opts = [...lb.querySelectorAll('[role="option"], [role="menuitem"], [data-option-index]')];
|
||
if (opts.length === 0) {
|
||
opts.push(...lb.querySelectorAll('li, [class*="option" i]:not([class*="optgroup" i])'));
|
||
}
|
||
const enabled = opts.filter(o =>
|
||
o.getAttribute('aria-disabled') !== 'true' &&
|
||
!o.classList.contains('disabled') &&
|
||
o.offsetHeight > 0
|
||
);
|
||
allOptions.push(...enabled);
|
||
}
|
||
|
||
for (const o of allOptions) {
|
||
const text = (o.textContent || '').trim().toLowerCase();
|
||
if (text === queryLower) {
|
||
o.click();
|
||
return { ok: true, method: 'exact', text: o.textContent.trim() };
|
||
}
|
||
}
|
||
for (const o of allOptions) {
|
||
const text = (o.textContent || '').trim().toLowerCase();
|
||
if (text.startsWith(queryLower)) {
|
||
o.click();
|
||
return { ok: true, method: 'startsWith', text: o.textContent.trim() };
|
||
}
|
||
}
|
||
for (const o of allOptions) {
|
||
const text = (o.textContent || '').trim().toLowerCase();
|
||
if (text.includes(queryLower)) {
|
||
o.click();
|
||
return { ok: true, method: 'contains', text: o.textContent.trim() };
|
||
}
|
||
}
|
||
|
||
return { ok: false, error: 'no-match-found', optionCount: allOptions.length };
|
||
}, [val]);
|
||
|
||
if (click.result?.ok) {
|
||
results.push({ value: val, ok: true, method: click.result.method, selected: click.result.text });
|
||
if (multi) {
|
||
await new Promise(r => setTimeout(r, 250));
|
||
}
|
||
} else {
|
||
results.push({ value: val, ok: false, error: click.result?.error || 'click-failed' });
|
||
}
|
||
} catch (e) {
|
||
results.push({ value: val, ok: false, error: e?.message || String(e) });
|
||
}
|
||
}
|
||
|
||
return { ok: results.every(r => r.ok), results };
|
||
}
|
||
|
||
// ── File Drop Helper (for drop-zones without <input type="file">) ───────────
|
||
|
||
async function dropFileOnTarget(tabId, selector, files) {
|
||
const fileList = Array.isArray(files) ? files : [files];
|
||
|
||
// Sweep any stale tags from previous failed runs before tagging fresh
|
||
await debuggerEval(tabId, `(() => {
|
||
document.querySelectorAll('[data-bmcp-drop-tag]').forEach(el => el.removeAttribute('data-bmcp-drop-tag'));
|
||
})()`);
|
||
|
||
// Strategy 1: search subtree (and 2 ancestor levels) for a file input — even if hidden
|
||
const inputJson = await debuggerEval(tabId, `(() => {
|
||
const target = document.querySelector(${JSON.stringify(selector)});
|
||
if (!target) return JSON.stringify({ found: false, error: 'target-not-found' });
|
||
|
||
const candidates = [];
|
||
candidates.push(...target.querySelectorAll('input[type="file"]'));
|
||
if (candidates.length === 0 && target.parentElement) {
|
||
candidates.push(...target.parentElement.querySelectorAll('input[type="file"]'));
|
||
}
|
||
if (candidates.length === 0 && target.parentElement?.parentElement) {
|
||
candidates.push(...target.parentElement.parentElement.querySelectorAll('input[type="file"]'));
|
||
}
|
||
if (candidates.length === 0) {
|
||
// Last resort: any file input on the page
|
||
candidates.push(...document.querySelectorAll('input[type="file"]'));
|
||
}
|
||
if (candidates.length === 0) return JSON.stringify({ found: false });
|
||
|
||
// Tag the first viable input with a unique data-attribute so we can re-query reliably
|
||
const tag = '__bmcp_drop_target_' + Math.random().toString(36).slice(2, 10);
|
||
candidates[0].setAttribute('data-bmcp-drop-tag', tag);
|
||
return JSON.stringify({ found: true, tag, accept: candidates[0].accept || '', multiple: !!candidates[0].multiple });
|
||
})()`);
|
||
|
||
const inputInfo = JSON.parse(inputJson);
|
||
|
||
if (inputInfo.found) {
|
||
const taggedSel = `[data-bmcp-drop-tag="${inputInfo.tag}"]`;
|
||
let result;
|
||
let caughtError;
|
||
try {
|
||
await debuggerAttach(tabId);
|
||
const docResult = await cdpSend(tabId, 'DOM.getDocument', {});
|
||
const queryResult = await cdpSend(tabId, 'DOM.querySelector', {
|
||
nodeId: docResult.root.nodeId,
|
||
selector: taggedSel,
|
||
});
|
||
if (queryResult.nodeId) {
|
||
await cdpSend(tabId, 'DOM.setFileInputFiles', {
|
||
nodeId: queryResult.nodeId,
|
||
files: fileList,
|
||
});
|
||
// Laeses HER, mens maerket stadig sidder - `finally` nedenfor fjerner det.
|
||
const vedhaeftet = await laesVedhaeftedeFiler(tabId, taggedSel);
|
||
result = fildSvar(vedhaeftet, fileList,
|
||
{ method: 'hidden-input', files: fileList, accept: inputInfo.accept });
|
||
}
|
||
} catch (e) {
|
||
caughtError = e?.message || String(e);
|
||
} finally {
|
||
// Always remove the tag attribute — success or failure
|
||
try {
|
||
await debuggerEval(tabId, `(() => {
|
||
const el = document.querySelector(${JSON.stringify(taggedSel)});
|
||
if (el) el.removeAttribute('data-bmcp-drop-tag');
|
||
})()`);
|
||
} catch {}
|
||
}
|
||
if (result) return result;
|
||
if (caughtError) {
|
||
return {
|
||
ok: false,
|
||
error: 'setFileInputFiles-failed',
|
||
detail: caughtError,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Strategy 2 (v1.27): intercept the NATIVE OS file chooser.
|
||
//
|
||
// Sites like Google Ads never put an <input type="file"> in the DOM — clicking
|
||
// their "choose a file" control opens the OS dialog directly, which no browser
|
||
// automation can reach. Page.setInterceptFileChooserDialog makes Chrome fire
|
||
// Page.fileChooserOpened instead of showing that dialog, and the event carries
|
||
// the backendNodeId of the element that requested it. DOM.setFileInputFiles
|
||
// accepts a backendNodeId, so we can satisfy the request programmatically.
|
||
//
|
||
// Order matters: interception must be armed BEFORE the click that opens the
|
||
// chooser, otherwise the OS dialog is already up and the event never fires.
|
||
const chooserResult = await interceptFileChooser(tabId, selector, fileList);
|
||
if (chooserResult) return chooserResult;
|
||
|
||
return {
|
||
ok: false,
|
||
error: 'no-file-input-found',
|
||
hint: 'No <input type="file"> found, and the native file-chooser interception did not fire. The trigger element may not open a file dialog at all — check the selector.',
|
||
};
|
||
}
|
||
|
||
// Arms Page.fileChooserOpened, clicks the trigger, and fulfils the chooser with
|
||
// the given files. Returns null if no chooser opened (so the caller can fall
|
||
// through to its own error), or a result object on success/explicit failure.
|
||
async function interceptFileChooser(tabId, selector, fileList) {
|
||
try {
|
||
await debuggerAttach(tabId);
|
||
await cdpSend(tabId, 'Page.enable', {});
|
||
await cdpSend(tabId, 'DOM.enable', {});
|
||
await cdpSend(tabId, 'Page.setInterceptFileChooserDialog', { enabled: true });
|
||
} catch (e) {
|
||
return null; // interception unavailable — let caller report the original error
|
||
}
|
||
|
||
try {
|
||
return await new Promise((resolve) => {
|
||
let settled = false;
|
||
const finish = (value) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
chrome.debugger.onEvent.removeListener(listener);
|
||
clearTimeout(timer);
|
||
resolve(value);
|
||
};
|
||
|
||
const listener = (source, method, eventParams) => {
|
||
if (source.tabId !== tabId || method !== 'Page.fileChooserOpened') return;
|
||
const backendNodeId = eventParams?.backendNodeId;
|
||
if (!backendNodeId) {
|
||
finish({ ok: false, error: 'file-chooser-without-node', detail: 'Chrome fired fileChooserOpened but supplied no backendNodeId.', note: 'The dialog exists, but not as an element in the page, so it cannot be filled from here. If you have desktop-level tools in this session (an OS automation MCP server such as computer-mcp), that dialog is theirs to drive.' });
|
||
return;
|
||
}
|
||
cdpSend(tabId, 'DOM.setFileInputFiles', { backendNodeId, files: fileList })
|
||
.then(() => finish({
|
||
ok: true,
|
||
method: 'native-chooser-intercepted',
|
||
files: fileList,
|
||
mode: eventParams.mode,
|
||
}))
|
||
.catch(e => finish({ ok: false, error: 'setFileInputFiles-failed', detail: e?.message || String(e) }));
|
||
};
|
||
chrome.debugger.onEvent.addListener(listener);
|
||
|
||
// 8s: the click is local, so the chooser opens within a frame or two.
|
||
// A longer wait would just stall the caller when the element opens no dialog.
|
||
const timer = setTimeout(() => finish(null), 8000);
|
||
|
||
// Click the trigger so the page asks for the chooser. Real mouse events —
|
||
// a synthetic .click() does not always reach the file-picker code path.
|
||
(async () => {
|
||
const el = await resolveElement(tabId, selector);
|
||
if (!el) { finish({ ok: false, error: 'trigger-not-found', detail: selector }); return; }
|
||
await debuggerClick(tabId, el.x, el.y);
|
||
})().catch(e => finish({ ok: false, error: 'trigger-click-failed', detail: e?.message || String(e) }));
|
||
});
|
||
} finally {
|
||
try { await cdpSend(tabId, 'Page.setInterceptFileChooserDialog', { enabled: false }); } catch {}
|
||
}
|
||
}
|
||
|
||
// ── Command Dispatcher ──────────────────────────────────────────────────────
|
||
|
||
async function dispatch(port, method, params) {
|
||
switch (method) {
|
||
case 'navigate': {
|
||
const session = getSession(port);
|
||
let tab = await getSessionTab(port);
|
||
|
||
// Always reuse the active tab — navigate in place, don't create new tabs
|
||
// Only create new tab if explicitly requested via new_tab param
|
||
if (params.new_tab) {
|
||
// EKSPERIMENT 19/9 (vindues-hypotesen, 1.30). Hele vores fejlklasse kommer af at
|
||
// Chrome ikke leverer Input.* til en fane der ikke er den viste i sit vindue - og
|
||
// maalingen 19/9 viste at det er en klasse konkurrenterne IKKE har, fordi de koerer
|
||
// deres egen browser. Faar en session sit EGET vindue, er dens fane altid den viste
|
||
// dér, uden at stjaele brugerens fokus. Saa ville klassen forsvinde.
|
||
//
|
||
// Det kunne ikke maales foer: vores egen kode fokuserer vinduet naar den aktiverer
|
||
// fanen, saa tilstanden "synlig i sit eget vindue, men ikke fokuseret" kunne ikke
|
||
// opnaas. `chrome.windows.create({ focused: false })` er praecis den tilstand.
|
||
// ⚠️ Tilvalg, ikke standard: om Chrome leverer input dér er PRAECIS det ubesvarede
|
||
// spoergsmaal. Ingen adfaerd aendrer sig for nogen der ikke beder om det.
|
||
if (params.eget_vindue) {
|
||
const vindue = await chrome.windows.create({ url: params.url, focused: false });
|
||
tab = vindue.tabs && vindue.tabs[0];
|
||
if (!tab) return { ok: false, error: 'eget_vindue: Chrome created a window with no tab' };
|
||
await addTabToSession(port, tab.id);
|
||
getSession(port).activeTabId = tab.id;
|
||
persistSessions();
|
||
return { ok: true, url: params.url, tabId: tab.id, windowId: vindue.id,
|
||
eget_vindue: true, fokuseret: false,
|
||
note: 'Experimental: the tab is the visible one in ITS window, but the window does not have focus. ' +
|
||
'Whether Chrome delivers mouse and keyboard input in that state is undecided - that is the whole ' +
|
||
'point of being able to create it. Measure it, do not rely on it.' };
|
||
}
|
||
tab = await chrome.tabs.create({ url: params.url, active: false });
|
||
await addTabToSession(port, tab.id);
|
||
} else {
|
||
await chrome.tabs.update(tab.id, { url: params.url });
|
||
}
|
||
|
||
// Wait for load
|
||
await new Promise(resolve => {
|
||
const listener = (tabId, info) => {
|
||
if (tabId === tab.id && info.status === 'complete') {
|
||
chrome.tabs.onUpdated.removeListener(listener);
|
||
resolve();
|
||
}
|
||
};
|
||
chrome.tabs.onUpdated.addListener(listener);
|
||
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 15000);
|
||
});
|
||
|
||
// Set as active tab for this session
|
||
session.activeTabId = tab.id;
|
||
persistSessions();
|
||
const updated = await chrome.tabs.get(tab.id);
|
||
|
||
// Check for CAPTCHA after navigation
|
||
const captcha = await detectCaptcha(tab.id);
|
||
const result = { title: updated.title, url: updated.url, tab_id: tab.id, session: session.label };
|
||
if (captcha && captcha.found) {
|
||
result.captcha_detected = captcha.types.join(', ');
|
||
result.hint = `CAPTCHA detected: ${captcha.types.join(', ')}. Use browser_solve_captcha to handle it.`;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
case 'get_page_content': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
|
||
const format = params.format || 'text';
|
||
// MAALT 17/9 mod Stripe Dashboard: `format:'html'` svarede 1.042.782 tegn, og der var
|
||
// ingen maade at bede om mindre. Vaerktoejet var dermed ubrugeligt praecis paa de store,
|
||
// indloggede apps hvor det er mest vaerd. To ting mangler: en vej til en DEL af siden,
|
||
// og en oevre graense der SIGER at den skar. En tavs afkortning er samme fejlklasse som
|
||
// resten af denne udgivelse: et svar der ser helt ud uden at vaere det.
|
||
const vaelger = params.selector || null;
|
||
const maxTegn = Number(params.max_chars) > 0 ? Number(params.max_chars) : 30000;
|
||
const scriptResult = await safeExecuteScript(tab.id, (fmt, sel) => {
|
||
const rod = sel ? document.querySelector(sel) : null;
|
||
if (sel && !rod) return { fundet: false };
|
||
const n = rod || document.documentElement;
|
||
return { fundet: true, tekst: fmt === 'html' ? n.outerHTML : (rod ? rod.innerText : document.body.innerText) };
|
||
}, [format, vaelger]);
|
||
|
||
let raa;
|
||
if (!scriptResult.cspBlocked) {
|
||
if (scriptResult.result && scriptResult.result.fundet === false) {
|
||
return { ok: false, error: 'Element not found: ' + vaelger,
|
||
note: 'Without a match the answer would be the whole page, and you would think you read what you asked for.' };
|
||
}
|
||
raa = scriptResult.result ? scriptResult.result.tekst : undefined;
|
||
} else {
|
||
const udtryk = vaelger
|
||
? `(() => { const el = document.querySelector(${JSON.stringify(vaelger)}); if (!el) return null; ` +
|
||
`return ${format === 'html' ? 'el.outerHTML' : 'el.innerText'}; })()`
|
||
: (format === 'html' ? 'document.documentElement.outerHTML' : 'document.body.innerText');
|
||
raa = await debuggerEval(tab.id, udtryk);
|
||
if (vaelger && raa == null) return { ok: false, error: 'Element not found: ' + vaelger, method: 'debugger' };
|
||
}
|
||
|
||
const metode = scriptResult.cspBlocked ? { method: 'debugger' } : {};
|
||
const tekst = typeof raa === 'string' ? raa : String(raa ?? '');
|
||
if (tekst.length > maxTegn) {
|
||
return {
|
||
content: tekst.slice(0, maxTegn), url: tab.url, title: tab.title, ...metode,
|
||
afkortet: true, tegn_i_alt: tekst.length,
|
||
note: `The page is ${tekst.length} characters; here are the first ${maxTegn}. Fetch the part you need ` +
|
||
'with `selector`, or raise `max_chars` if you really need all of it.',
|
||
};
|
||
}
|
||
return { content: tekst, url: tab.url, title: tab.title, ...metode };
|
||
}
|
||
|
||
// Read EVERY row of a virtualised list by scrolling its container until the set stops
|
||
// growing. Outlook, Gmail and most mail/table UIs keep only ~7 rows in the DOM, so a
|
||
// single get_page_content sees a sliver — this walks the whole list instead.
|
||
case 'extract_list': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
|
||
const rowSel = params.selector;
|
||
if (!rowSel) throw new Error('extract_list requires `selector` (the repeating row element)');
|
||
const containerSel = params.container || null;
|
||
const maxRows = Math.min(params.max_rows || 500, 5000);
|
||
const stableNeeded = params.stable_rounds || 3;
|
||
const waitMs = params.wait_ms || 350;
|
||
|
||
const seen = new Set();
|
||
let stable = 0, rounds = 0, atEnd = false;
|
||
const MAX_ROUNDS = 300; // backstop: a list that never stabilises must not spin forever
|
||
|
||
while (seen.size < maxRows && stable < stableNeeded && rounds < MAX_ROUNDS) {
|
||
rounds++;
|
||
const r = await safeExecuteScript(tab.id, (rs, cs, keep) => {
|
||
const rows = Array.from(document.querySelectorAll(rs));
|
||
const texts = rows
|
||
.map(el => (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim())
|
||
.filter(t => t.length > 0);
|
||
|
||
// Scroll the row's own scrollable ancestor — scrolling window does nothing when the
|
||
// list lives in an inner overflow container (the normal case in webmail).
|
||
let c = cs ? document.querySelector(cs) : null;
|
||
if (!c && rows.length) {
|
||
let p = rows[0].parentElement;
|
||
while (p && p !== document.documentElement) {
|
||
const s = getComputedStyle(p);
|
||
if (/(auto|scroll)/.test(s.overflowY) && p.scrollHeight > p.clientHeight + 20) { c = p; break; }
|
||
p = p.parentElement;
|
||
}
|
||
}
|
||
const step = keep || (c ? c.clientHeight : window.innerHeight) * 0.85;
|
||
const before = c ? c.scrollTop : window.scrollY;
|
||
if (c) c.scrollTop = before + step; else window.scrollBy(0, step);
|
||
const after = c ? c.scrollTop : window.scrollY;
|
||
const done = c
|
||
? c.scrollTop + c.clientHeight >= c.scrollHeight - 4
|
||
: window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4;
|
||
return { texts, moved: after - before, done, container: !!c };
|
||
}, [rowSel, containerSel, params.scroll_step || 0]);
|
||
|
||
if (r.cspBlocked) throw new Error('extract_list: this page blocks script injection — use screenshots instead');
|
||
const data = r.result || { texts: [] };
|
||
const before = seen.size;
|
||
for (const t of data.texts) seen.add(t);
|
||
// Two independent stop signals: nothing new appeared, or the container hit its end.
|
||
if (seen.size === before) stable++; else stable = 0;
|
||
if (data.done) { atEnd = true; stable++; }
|
||
if (!data.texts.length && rounds > 2) break; // selector matches nothing — fail fast
|
||
await new Promise(res => setTimeout(res, waitMs));
|
||
}
|
||
|
||
return {
|
||
rows: [...seen],
|
||
count: seen.size,
|
||
rounds,
|
||
reached_end: atEnd,
|
||
truncated: seen.size >= maxRows,
|
||
};
|
||
}
|
||
|
||
case 'screenshot': {
|
||
// EKSPERIMENT 21/8: fanen aktiveres IKKE laengere.
|
||
//
|
||
// getSessionTab(…, true) stjal ikke VINDUES-fokus (FIX-1), men den kaldte stadig
|
||
// chrome.tabs.update({active:true}) — altsaa et fane-skift INDE i vinduet. Sidder
|
||
// brugeren i det samme Chrome-vindue paa sin egen fane, bliver den revet vaek hver
|
||
// eneste gang agenten tager et billede. Og billeder tages konstant.
|
||
//
|
||
// Aktiveringen er formentlig unoedvendig: CDP Page.captureScreenshot nedenfor
|
||
// fotograferer en fane der ikke er forrest. Den okkluderede sidste-udvej loefter
|
||
// stadig vinduet hvis CDP virkelig ikke kan producere en frame.
|
||
const tab = await getSessionTab(port, false);
|
||
if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:')) {
|
||
throw new Error(`Cannot screenshot ${tab.url.split(':')[0]}: pages — navigate to a real page first`);
|
||
}
|
||
// MAALT 10/9, anden runde (Astra, reproduceret): reserveloesningen captureVisibleTab
|
||
// fotograferer den fane der er SYNLIG i vinduet, ikke agentens. Et tjek foer og et efter
|
||
// kunne ikke udelukke at brugeren skiftede A->B->A imens - og saa blev brugerens side
|
||
// leveret. To tidspunkter beviser ikke hvad der skete imellem. Reserveloesningen er derfor
|
||
// fjernet: CDP optager netop `tab.id`, eller der er intet billede.
|
||
// Samme runde: en frist paa optagelsen startede alligevel en ny runde (haev vinduet, optag
|
||
// igen), og kaeden kom over serverens 30 s. En frist markeres nu, og saa proeves der ikke igen.
|
||
// Sign-off 11/9 (Astra, R5 F8): hele kaeden har ét budget (skaermbilledeFrister). Fristen gaelder ogsaa mens
|
||
// cdpSend gentager et kald efter en afkobling - det var den gentagelse der bar kaeden over 30 s.
|
||
const { foersteMs, samletMs, haevMs = 0 } = skaermbilledeFrister();
|
||
const budgetSlut = Date.now() + samletMs;
|
||
// MAALT 11/9 af Astra (e2e runde 2), tre fejl i den haevede runde: reservationen blev trukket fra IGEN (reserven fik
|
||
// 0 ms), `debuggerAttach` laa uden for fristloebet (en gentilslutning paa 12,5 s bar kaeden over 30 s), og haevningen
|
||
// startede nye optagelser og smed de igangvaerende vaek - 1.29.0 fik netop svar fra dem der allerede loeb.
|
||
const igangvaerende = []; // alle optagelser der stadig kan svare, paa tvaers af runder
|
||
const tryCapture = async ({ sidsteUdvej = false } = {}) => {
|
||
const reserveret = sidsteUdvej ? 0 : haevMs; // kun foerste runde holder tid fri til haevningen
|
||
const denneRunde = [];
|
||
const optag = (p) => {
|
||
const kald = cdpSend(tab.id, 'Page.captureScreenshot', p);
|
||
kald.catch(() => {}); // et svar efter budgettet er ligegyldigt
|
||
igangvaerende.push(kald);
|
||
denneRunde.push(kald);
|
||
return kald;
|
||
};
|
||
const medFrist = async (loefte, ms) => {
|
||
let ur;
|
||
try {
|
||
return await Promise.race([
|
||
loefte,
|
||
new Promise((_, afvis) => {
|
||
const frist = Math.max(0, ms);
|
||
ur = setTimeout(() => afvis(cdpFristFejl(`CDP did not respond within ${frist} ms: Page.captureScreenshot`)), frist);
|
||
}),
|
||
]);
|
||
} catch (e) {
|
||
if (e && typeof e === 'object' && erCdpFrist(e)) e.ingenNyRunde = true;
|
||
throw e;
|
||
} finally {
|
||
clearTimeout(ur);
|
||
}
|
||
};
|
||
// Tilslutningen skal ogsaa ligge inde i budgettet: en gentilslutning kan tage lang tid paa et haevet vindue.
|
||
await medFrist(debuggerAttach(tab.id), budgetSlut - reserveret - Date.now());
|
||
const standard = optag({ format: 'png' });
|
||
try {
|
||
const shot = await medFrist(standard, Math.min(foersteMs, budgetSlut - reserveret - Date.now()));
|
||
return { image: 'data:image/png;base64,' + shot.data };
|
||
} catch (foersteFejl) {
|
||
// fromSurface:false proeves ogsaa efter en frist: i 1.29.0 var det netop fristen der naaede hertil, og den
|
||
// leverede billedet. Men efter en frist startes ingen runde mere, uanset hvordan reserven fejler.
|
||
// MAALT 11/9 af Astra (efterproevning af f084d1b): standardoptagelsen lykkedes efter 11 s, reserven fejlede, og
|
||
// fristen paa 10 s havde kasseret standardbilledet. Standardoptagelsen loeber derfor videre efter sin frist, og
|
||
// den af de to der lykkes foerst inden for budgettet, vinder.
|
||
optag({ format: 'png', fromSurface: false, captureBeyondViewport: false });
|
||
try {
|
||
// Den haevede runde er sidste udvej paa en tildaekket skaerm og skal have tid tilbage (haevMs) - ellers naar
|
||
// 1.29.0's eneste virkende vej aldrig frem (Fable, e2e 11/9).
|
||
// MAALT 12/9 af Astra: alle igangvaerende optagelser laa i den SAMME Promise.any, saa et gammelt billede fra
|
||
// foer haevningen kunne svare foerst og vinde - selv om siden havde aendret sig imens. Rundens EGNE optagelser
|
||
// afgoer nu svaret; de aeldre taeller stadig med, men kun hvis rundens egne ikke naar frem.
|
||
let shot;
|
||
try {
|
||
shot = await medFrist(Promise.any(denneRunde), budgetSlut - reserveret - Date.now());
|
||
} catch (rundeFejl) {
|
||
const aeldre = igangvaerende.filter((k) => !denneRunde.includes(k));
|
||
if (!aeldre.length) throw rundeFejl;
|
||
// Et aeldre billede der ALLEREDE er kommet, svarer i samme oejeblik - ogsaa naar budgettet er brugt op.
|
||
shot = await medFrist(Promise.any(aeldre), budgetSlut - Date.now());
|
||
}
|
||
return { image: 'data:image/png;base64,' + shot.data };
|
||
} catch (andenFejl) {
|
||
const fejl = andenFejl instanceof AggregateError ? andenFejl.errors[andenFejl.errors.length - 1] : andenFejl;
|
||
if (foersteFejl?.ingenNyRunde && fejl && typeof fejl === 'object') fejl.ingenNyRunde = true;
|
||
throw fejl;
|
||
}
|
||
}
|
||
};
|
||
|
||
// Attempt 1 — focus-neutral. Handles the vast majority (background-but-visible window).
|
||
try {
|
||
return await tryCapture();
|
||
} catch (firstErr) {
|
||
// 10/9 (Astra, R2) stod her: en frist betyder at kompositoren ikke svarede, saa en ny runde fordobler kun
|
||
// ventetiden. MAALT 11/9 af Fable: paa en TILDAEKKET skaerm haenger begge optagelser netop indtil vinduet haeves -
|
||
// 1.29.0 leverede dér et billede efter 16,7 s, hvor HEAD gav op. Budgettet (haevMs) holder kaeden under serverens
|
||
// 30 s, saa den sidste udvej maa proeves uanset om fejlen var en frist.
|
||
// Er budgettet brugt, er der ikke tid til en runde med haevet vindue foer serverens 30 s. (haevMs ovenfor er netop
|
||
// den tid der blev holdt fri til den her runde.)
|
||
if (budgetSlut - Date.now() <= 0) throw firstErr;
|
||
// Both methods failed → the window is genuinely OCCLUDED (covered by other windows),
|
||
// so Chrome\'s compositor produced no frames. LAST RESORT ONLY: raise the window to
|
||
// de-occlude it, capture, then RESTORE the user\'s previously-focused window. This
|
||
// focus-steal happens ONLY in the rare covered case — never on a normal screenshot.
|
||
const prev = await chrome.windows.getLastFocused().catch(() => null);
|
||
try {
|
||
await chrome.windows.update(tab.windowId, { focused: true, state: 'normal' });
|
||
await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
|
||
await new Promise(r => setTimeout(r, 250)); // let it composite
|
||
return await tryCapture({ sidsteUdvej: true }); // resten af budgettet - der er ikke en runde mere efter denne
|
||
} catch (secondErr) {
|
||
throw new Error(
|
||
`Screenshot failed after focus-neutral AND raised attempts. ` +
|
||
`First: ${firstErr?.message || firstErr}. Raised: ${secondErr?.message || secondErr}. ` +
|
||
`If both say "image readback failed" the GPU compositor is not producing frames — ` +
|
||
`disable Chrome hardware acceleration (chrome://settings/system) as a last resort.`
|
||
);
|
||
} finally {
|
||
// Give focus back to the user\'s previous Chrome window (best-effort; getLastFocused
|
||
// only sees Chrome windows, so a non-Chrome IDE can't be re-focused programmatically).
|
||
if (prev && prev.id != null && prev.id !== tab.windowId) {
|
||
await chrome.windows.update(prev.id, { focused: true }).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
case 'execute_script': {
|
||
// v1.22.2 (DIAGNOSTIC): Try scripting paths but log all errors so we can see WHY they fail
|
||
// v1.26: accept `script` as alias for `code` — the historic param-name mismatch caused
|
||
// silent "unserializable"/undefined failures that read as "execute_script is broken".
|
||
if (params.code == null && typeof params.script === 'string') params.code = params.script;
|
||
if (typeof params.code !== 'string' || !params.code.trim()) {
|
||
return { ok: false, error: 'Missing code. Pass a JavaScript EXPRESSION in `code` (e.g. an IIFE: (() => {...; return x;})()). `return ...` at top level is invalid — the handler wraps code in parentheses.' };
|
||
}
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot execute scripts on chrome:// pages');
|
||
|
||
const diag = { tried: [] };
|
||
|
||
// MAALT 10/9 af Astra (anden runde), reproduceret med en taeller: kode der udfoerte en
|
||
// effekt og SAA kastede, blev koert igen via debuggeren - to effekter. `sendt` beskyttede
|
||
// kun debugger-loekken. Er koden koert, er dens fejl svaret; den koeres ikke igen.
|
||
// Samme runde: scripting-stierne afventede ikke et Promise ("Promise.resolve(42)" gav {}).
|
||
// Den injicerede funktion er nu async og afventer resultatet.
|
||
const koertOgFejlede = (r) => ({
|
||
ok: false, error: r.message, name: r.name,
|
||
method: r.world === 'MAIN' ? 'scripting-main' : 'scripting-isolated',
|
||
note: 'The code RAN and threw an error. It is not run again through the debugger, because what it ' +
|
||
'did manage before the error would then happen twice.',
|
||
});
|
||
// MAALT 11/9 af Astra (R5 F4), reproduceret: scriptet sendte en POST og returnerede et
|
||
// Promise; mens scripting-stien ventede, forsvandt dokumentet, og Chrome afviste med
|
||
// "Frame with ID 0 was removed." Handleren gik saa videre til debuggeren, som koerte koden
|
||
// igen - to POST'er. Forsvinder siden EFTER at koden er startet, kan den have koert, og
|
||
// den koeres ikke igen. Blev den aldrig indsproejtet, maa debuggeren stadig proeve.
|
||
const sidenForsvandt = (m) => /was removed|execution context was destroyed|document (was )?unloaded/i.test(m);
|
||
const maaskeKoert = (world, m) => ({
|
||
ok: false, error: m, maybe_ran: true,
|
||
method: world === 'MAIN' ? 'scripting-main' : 'scripting-isolated',
|
||
note: 'The page changed or closed while the code was running. It may already have run, so it ' +
|
||
'is not run again through the debugger. Call again only if it is safe to run twice.',
|
||
});
|
||
// Step 1: try ISOLATED world
|
||
try {
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId: tab.id },
|
||
world: 'ISOLATED',
|
||
args: [params.code],
|
||
func: async (codeStr) => {
|
||
// Oversaettelse og koersel er adskilt: fejler oversaettelsen (CSP, syntaks), koerte
|
||
// intet, og debuggeren maa proeve. Fejler KOERSLEN, er koden allerede koert.
|
||
let fn;
|
||
try { fn = new Function('return (' + codeStr + ')'); }
|
||
catch (e) { return { __kompilering: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' }; }
|
||
try { return { __ok: true, value: await fn() }; }
|
||
catch (e) { return { __scriptingError: true, koerte: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' }; }
|
||
},
|
||
});
|
||
const r = result?.result;
|
||
diag.tried.push({ world: 'ISOLATED', result_keys: r ? Object.keys(r) : null, r_type: typeof r });
|
||
if (r && typeof r === 'object' && r.__ok) {
|
||
return { result: r.value, method: 'scripting-isolated' };
|
||
}
|
||
if (r && typeof r === 'object' && r.__scriptingError && r.koerte) return koertOgFejlede(r);
|
||
if (r && typeof r === 'object' && (r.__scriptingError || r.__kompilering)) {
|
||
diag.isolated_error = r.message;
|
||
}
|
||
} catch (e) {
|
||
// Ingen maybe_ran her. MAALT 11/9 i Chrome for Testing 153: `new Function` i ISOLATED afvises af udvidelsens
|
||
// CSP ('unsafe-eval'), saa brugerens kode kan aldrig have koert i denne verden. Sign-off (Astra): en afvisning
|
||
// med "Frame with ID 0 was removed." foer start gav maybe_ran og nul koersler, hvor 1.29.0 koerte koden via MAIN.
|
||
diag.isolated_throw = String(e?.message || e);
|
||
}
|
||
|
||
// Step 2: try MAIN world
|
||
try {
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId: tab.id },
|
||
world: 'MAIN',
|
||
args: [params.code],
|
||
func: async (codeStr) => {
|
||
// Oversaettelse og koersel er adskilt: fejler oversaettelsen (CSP, syntaks), koerte
|
||
// intet, og debuggeren maa proeve. Fejler KOERSLEN, er koden allerede koert.
|
||
let fn;
|
||
try { fn = new Function('return (' + codeStr + ')'); }
|
||
catch (e) { return { __kompilering: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' }; }
|
||
try { return { __ok: true, value: await fn() }; }
|
||
catch (e) { return { __scriptingError: true, koerte: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' }; }
|
||
},
|
||
});
|
||
const r = result?.result;
|
||
diag.tried.push({ world: 'MAIN', result_keys: r ? Object.keys(r) : null, r_type: typeof r });
|
||
if (r && typeof r === 'object' && r.__ok) {
|
||
return { result: r.value, method: 'scripting-main' };
|
||
}
|
||
if (r && typeof r === 'object' && r.__scriptingError && r.koerte) return koertOgFejlede(r);
|
||
if (r && typeof r === 'object' && (r.__scriptingError || r.__kompilering)) {
|
||
diag.main_error = r.message;
|
||
}
|
||
} catch (e) {
|
||
const m = String(e?.message || e);
|
||
if (sidenForsvandt(m)) return maaskeKoert('MAIN', m);
|
||
diag.main_throw = m;
|
||
}
|
||
|
||
// Step 3: debugger fallback — the ONLY universal path for arbitrary STRING code
|
||
// (both scripting worlds block `new Function`: ISOLATED via MV3 extension-CSP,
|
||
// MAIN via the page\'s own unsafe-eval CSP). CDP Runtime.evaluate bypasses CSP.
|
||
// FIX (2026-07-16): retry on an EMPTY/undefined CDP response. On some pages the
|
||
// debugger auto-detaches mid-command and `chrome.debugger.sendCommand` RESOLVES
|
||
// with `undefined` instead of rejecting, so cdpSend's throw-based retry never
|
||
// fires and debuggerEval silently returned undefined → the caller saw a bare
|
||
// `{method:"debugger"}` with no result. Also surface script exceptions + raw
|
||
// diagnostics so a genuine failure is never mistaken for an empty success.
|
||
let rawDbg, dbgErr = '', sendt = false;
|
||
for (let attempt = 0; attempt < 4; attempt++) {
|
||
try {
|
||
await debuggerAttach(tab.id);
|
||
// MAALT 10/9 af Astra: faldt debuggeren af EFTER at scriptet var sendt, loeb loekken
|
||
// videre og koerte BRUGERENS kode igen — op til fire gange. Vi kan ikke se om et
|
||
// vilkaarligt script muterer. Samme regel som for Runtime.evaluate i cdpSend: er det
|
||
// sendt, gentages det ikke automatisk. Kun en fejl FOER afsendelse proeves igen.
|
||
sendt = true;
|
||
rawDbg = await cdpSend(tab.id, 'Runtime.evaluate', {
|
||
expression: '(' + params.code + '\n)',
|
||
returnByValue: true,
|
||
awaitPromise: true,
|
||
});
|
||
if (rawDbg && rawDbg.exceptionDetails) {
|
||
const ex = rawDbg.exceptionDetails;
|
||
await debuggerDetach(tab.id).catch(() => {});
|
||
throw new Error('__SCRIPT_EX__' + (ex.exception?.description || ex.text || 'Script exception'));
|
||
}
|
||
if (rawDbg && rawDbg.result && rawDbg.result.type !== 'undefined') {
|
||
await debuggerDetach(tab.id).catch(() => {});
|
||
return { result: rawDbg.result.value, method: 'debugger' };
|
||
}
|
||
dbgErr = 'empty/undefined CDP response: ' + JSON.stringify(rawDbg);
|
||
break; // tomt svar efter afsendelse: scriptet kan have koert — gentag ikke
|
||
} catch (e) {
|
||
const m = String(e?.message || e);
|
||
if (m.startsWith('__SCRIPT_EX__')) {
|
||
throw new Error(m.slice('__SCRIPT_EX__'.length) + ' | scripting-diag: ' + JSON.stringify(diag));
|
||
}
|
||
dbgErr = m;
|
||
if (sendt) break; // sendt = maaske koert — se kommentaren ved afsendelsen
|
||
if (!/detach|attach|empty|gone|given id|not attached/i.test(m)) break;
|
||
}
|
||
await debuggerDetach(tab.id).catch(() => {});
|
||
await new Promise(r => setTimeout(r, 200 + attempt * 200));
|
||
}
|
||
throw new Error(
|
||
'execute_script failed on all paths. debugger: ' + dbgErr +
|
||
(sendt ? ' | Note: the script was sent to the page before it failed and MAY already have run. ' +
|
||
'It is not repeated automatically - call again only if it is safe to run twice.' : '') +
|
||
' | raw: ' + JSON.stringify(rawDbg) +
|
||
' | scripting-diag: ' + JSON.stringify(diag)
|
||
);
|
||
}
|
||
|
||
case 'click': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
|
||
// Wrap full click flow (incl. resolveElement) so debugger failures in EITHER
|
||
// resolveElement (text-selectors use debuggerEval) OR debuggerClick trigger
|
||
// the scripting-fallback. v1.21.2: previously only debuggerClick was wrapped,
|
||
// leaving text-selector clicks unrecoverable when debugger was user-blocked.
|
||
try {
|
||
const el = await resolveElement(tab.id, params.selector);
|
||
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
||
// Elementet findes, men har ingen udstraekning — at klikke ville ramme (0,0),
|
||
// altsaa et HELT andet element end det der blev bedt om. Sig det i stedet.
|
||
if (el.hidden) {
|
||
return {
|
||
ok: false,
|
||
error: 'Element found but not visible (0x0) - a click would hit the page corner: ' + params.selector,
|
||
hidden: true,
|
||
tag: el.tag,
|
||
};
|
||
}
|
||
|
||
// Primary path: debugger mouse events (isTrusted=true, works on React/Angular SPAs)
|
||
const clickResult = await debuggerClick(tab.id, el.x, el.y);
|
||
return {
|
||
method: el.method || 'debugger',
|
||
tag: el.tag,
|
||
text: el.text,
|
||
// MAALT 21/8: `landed` blev allerede beregnet inde i debuggerClick og smidt vaek,
|
||
// saa `click` svarede ok:true selv naar siden slet ikke reagerede. Nu foelger den med:
|
||
// landed=false betyder "eventet blev sendt, men intet handler tog imod det".
|
||
...(clickResult || {}),
|
||
// MAALT 9/9 (issue #19, fjerde gang samme fejlklasse efter select_option og fill):
|
||
// `ok: true` stod hardkodet, og `landed` blev spredt ind bagefter. Klikket svarede
|
||
// altsaa ja og nej i samme aandedrag, og en agent laeser `ok`.
|
||
// Et element der forsvandt ER en virkning — derfor tæller `detached` som landet.
|
||
// MAALT 12/9 af Astra (N1): `ok` stod FOER settle-vaerdien her og i click_xy, hvor select_option var immun -
|
||
// saa et svar fra siden kunne bestemme vaerktoejets egen dom. Ikke naabart i dag, men det er samme klasse som
|
||
// lige blev lukket for noten, og de tre steder var indbyrdes uens. Vaerktoejets vurdering staar nu sidst.
|
||
ok: klikLandede(clickResult),
|
||
...(uvisVurdering(clickResult) || {}),
|
||
};
|
||
} catch (e) {
|
||
// Fallback: synthetic click via chrome.scripting for anti-automation sites
|
||
// (Apple ASC etc.) OR user-blocked-debugger scenarios.
|
||
//
|
||
// MÅLT 29/7: betingelsen var kun /Debugger detached/, men den HYPPIGSTE fejl hedder
|
||
// "Debugger attach failed after 3 attempts" (kastes l.298) — altså når Chrome nægter
|
||
// at koble debuggeren på overhovedet. De to strenge ligner hinanden og betyder næsten
|
||
// det samme, men regexet ramte kun den ene, så fallbacken fyrede aldrig i det tilfælde
|
||
// den var skrevet til: "user-blocked-debugger scenarios" står ordret i kommentaren
|
||
// ovenfor, og det var netop dét den ikke dækkede.
|
||
//
|
||
// Konsekvens i praksis: klikker brugeren Cancel på Chromes debugger-banner ÉN gang,
|
||
// husker Chrome det på tværs af extension-reloads, og hvert eneste klik fejler
|
||
// permanent — selvom scriptingClick ville have virket hele tiden. Den bruger `func:`
|
||
// og ikke en kode-streng, så den rammes ikke af sidens CSP.
|
||
//
|
||
// Prisen ved fallbacken er at klikket mister isTrusted=true. Det tjekker de færreste
|
||
// sider, og et klik der virker på 95% af nettet slår et klik der aldrig virker.
|
||
// MAALT 10/9 af Astra (anden runde), reproduceret: museknappen var sendt ned og op, CDP meldte
|
||
// derefter afkobling - og reserveloesningen klikkede EN GANG TIL. To effekter, ok:true. Er
|
||
// trykket sendt, kan klikket vaere landet; saa klikkes der ikke igen.
|
||
if (e?.trykSendt) {
|
||
return {
|
||
ok: false, error: e.message, maybe_landed: true, method: 'debugger',
|
||
note: 'The mouse click was sent, but the debugger detached afterwards. The click MAY have landed, ' +
|
||
'so it is not repeated. Check the page before clicking again.',
|
||
};
|
||
}
|
||
// MAALT 11/9 i Chrome for Testing: en fane i baggrunden faar ikke Input.* - musebevaegelsen udloeber FOER
|
||
// trykket er sendt. Intet klik kan vaere landet (trykSendt er falsk, se ovenfor), saa script-klikket er sikkert.
|
||
const inputFristFoerTryk = erCdpFrist(e) && /: Input\./.test(e?.message || '');
|
||
if (inputFristFoerTryk || /Debugger detached|Debugger attach failed|not attached/i.test(e?.message || '')) {
|
||
// Ingen "ny adresse = klikket navigerede"-regel her. Astra (efterproevning af c1496d4): en UAFHAENGIG navigation
|
||
// fjernede rammen foer scriptet koerte, og reglen svarede ok:true med nul handlinger. En afvisning beviser ikke at
|
||
// scriptet koerte, og en ny adresse beviser ikke at det var klikket.
|
||
const r = await scriptingClick(tab.id, params.selector);
|
||
if (r.ok && !inputFristFoerTryk) {
|
||
if (klikLandede(r)) return { ok: true, method: 'scripting-fallback', tag: r.tag, landed: true };
|
||
// MAALT 17/9 mod Stripe Dashboard: her stod et bart `ok: true` naar klikket ikke var bevist, med
|
||
// begrundelsen "samme svar som 1.29.0". Efter en time med "Debugger attach failed ... ghost" svarede
|
||
// vaerktoejet {ok:true, tag:'DIV'} paa «Create key» - knappen blev aldrig trykket, siden stod uaendret.
|
||
// Nabogrenen nedenfor kraevede allerede bevis; denne var bare aldrig blevet rettet.
|
||
//
|
||
// ⛔ Svaret er IKKE ok:false. Astra maalte 12/9 at en menu der aabner paa mousedown ellers meldes
|
||
// mislykket, og saa klikker agenten igen og lukker den. Det er det TREDJE udfald, som resten af
|
||
// klassen bruger: sendt, virkning unknown.
|
||
const unknown = uvisVurdering(r);
|
||
return {
|
||
ok: true, method: 'scripting-fallback', tag: r.tag, landed: null, maybe_landed: true,
|
||
note: (unknown?.note ? unknown.note + ' ' : '') +
|
||
'The debugger was blocked, so the click was sent with a script. The page showed no measurable ' +
|
||
'effect, so it is unknown whether it worked - some pages require a real click. Check the page ' +
|
||
'before clicking again.',
|
||
};
|
||
}
|
||
if (r.ok) {
|
||
// Sign-off 11/9 (Astra og Fable): paa en baggrundsfane svarede reserven ok:true, ogsaa naar siden intet gjorde
|
||
// (handler der kraever isTrusted). 1.29.0 svarede med en fejl. Nu kraever ok samme bevis som de andre klikveje.
|
||
// Viste siden ingen reaktion, er klikket alligevel sendt - saa det meldes som "kan vaere landet", ikke gentag blindt.
|
||
if (klikLandede(r)) {
|
||
return {
|
||
ok: true, method: 'scripting-fallback', tag: r.tag, landed: true,
|
||
note: 'The tab was in the background, so mouse events did not arrive. The click was performed with a script instead.',
|
||
};
|
||
}
|
||
return {
|
||
ok: false, method: 'scripting-fallback', tag: r.tag, landed: false, maybe_landed: true, error: e.message,
|
||
note: 'The tab was in the background, so mouse events did not arrive. A script click was sent, but the click itself ' +
|
||
'produced no visible effect: either the page requires a real click, or the click worked without a visible change. Check the page, ' +
|
||
'and call browser_switch_tab and click again only if nothing happened.',
|
||
};
|
||
}
|
||
}
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
case 'fill': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const parsed = parseSelector(params.selector);
|
||
|
||
// For text-based selectors, click the element first then type
|
||
if (parsed.type === 'text') {
|
||
const el = await resolveElement(tab.id, params.selector);
|
||
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
||
await debuggerClick(tab.id, el.x, el.y);
|
||
await new Promise(r => setTimeout(r, 100));
|
||
await debuggerType(tab.id, params.value);
|
||
// MAALT 13/9 (Astras hul-audit): her stod `ok:true` uden at nogen havde set feltet.
|
||
// Css-grenen laeser allerede vaerdien tilbage og skelner tomt fra fordoblet fra
|
||
// formateret; tekst-grenen gjorde ikke. Et klik der landede paa noget andet end et
|
||
// felt, eller en baggrundsfane der slugte tasterne, gav samme ja.
|
||
const efterTekst = await debuggerEval(tab.id,
|
||
'(() => { const a = document.activeElement; return a && "value" in a ? String(a.value) : null; })()')
|
||
.catch(() => undefined);
|
||
if (efterTekst === undefined || efterTekst === null) {
|
||
return { ok: true, method: 'debugger', unknown: true,
|
||
note: 'The text was written, but the field could not be read afterwards, so it is unknown whether ' +
|
||
'it landed. Read the field with browser_execute_script if it matters.' };
|
||
}
|
||
if (efterTekst === String(params.value)) return { ok: true, method: 'debugger', value: efterTekst };
|
||
if (efterTekst === '') {
|
||
return { ok: false, method: 'debugger', error: 'field-is-empty', value: efterTekst,
|
||
note: 'The field was empty after the write. The click may not have hit a field, or the tab ' +
|
||
'er i baggrunden, hvor Chrome does not deliver keystrokes. Call browser_switch_tab and try again.' };
|
||
}
|
||
return { ok: true, method: 'debugger', differs: true, value: efterTekst,
|
||
note: 'The field contains something other than what was typed. The page has probably formatted ' +
|
||
'the value - or something was already there.' };
|
||
}
|
||
|
||
// Always use debugger for input/textarea — React/Angular/Vue need real keyboard events
|
||
try {
|
||
const efterFyld = await debuggerFill(tab.id, parsed.selector, params.value);
|
||
return fyldSvar(efterFyld?.value, params.value, { method: 'debugger' },
|
||
efterFyld?.rammeHoerte);
|
||
} catch (e) {
|
||
// MAALT 10/9 af Astra: debugger-vejen timede ud, og reserveloesningen skrev saa hele
|
||
// vaerdien med den native setter. Men Promise.race afbryder ikke — tastetrykkene fra
|
||
// debugger-forsoeget kan lande BAGEFTER. Reproduceret: "X" blev til "XX", og kaldet
|
||
// svarede ok:true. To regler: er vaerdien der allerede efter en frist, er vi faerdige;
|
||
// og efter reserveloesningen laeses feltet igen, saa en fordobling ses i stedet for
|
||
// at blive meldt som succes.
|
||
const laesFelt = async () => {
|
||
const r = await safeExecuteScript(tab.id, (sel) => {
|
||
const el = document.querySelector(sel);
|
||
return el && 'value' in el ? el.value : null;
|
||
}, [parsed.selector]).catch(() => null);
|
||
return r && !r.cspBlocked ? r.result : null;
|
||
};
|
||
const fristUdloeb = erCdpFrist(e);
|
||
if (fristUdloeb) await new Promise((r) => setTimeout(r, 400));
|
||
// MAALT 11/9 af Astra (R5 F1): feltet laeses FOER reserveloesningen skriver. Det er det der skiller en
|
||
// side der afviste vaerdien (feltet stod stille) fra en side der formaterede den (feltet aendrede sig).
|
||
const foer = await laesFelt();
|
||
if (fristUdloeb && foer === params.value) {
|
||
return { ok: true, method: 'debugger', note: 'landede trods fristen' };
|
||
}
|
||
// Fallback to executeScript if debugger fails
|
||
const scriptResult = await safeExecuteScript(tab.id, (sel, val) => {
|
||
const el = document.querySelector(sel);
|
||
if (!el) return { ok: false, error: 'Element not found: ' + sel };
|
||
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||
el.focus();
|
||
// Use nativeInputValueSetter to bypass React controlled input
|
||
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||
if (setter) setter.call(el, val); else el.value = val;
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return { ok: true };
|
||
}, [parsed.selector, params.value]);
|
||
if (scriptResult.cspBlocked) return { ok: false, error: e.message, method: 'debugger' };
|
||
if (!scriptResult.result?.ok) return scriptResult.result;
|
||
await new Promise((r) => setTimeout(r, 300));
|
||
const endelig = await laesFelt();
|
||
// Kun to sluttilstande er fejl. FORDOBLET: et forsinket Input.insertText landede efter
|
||
// setteren ("X" -> "XX", reproduceret). TOEMT: et forsinket Cmd+A/Backspace ryddede feltet
|
||
// igen. Alt andet — "5" der bliver til "5,00 kr", et telefonnummer med mellemrum — er
|
||
// feltets egen formatering og maa ikke meldes som fejl.
|
||
const v = String(params.value ?? '');
|
||
if (typeof endelig === 'string' && endelig !== v) {
|
||
const fordoblet = endelig === v + v || (v.length >= 3 && endelig.includes(v + v));
|
||
if (fordoblet || (v && endelig === '')) {
|
||
return {
|
||
ok: false, method: 'fallback', error: fordoblet ? 'field-doubled' : 'field-cleared',
|
||
expected: v, actual: endelig,
|
||
note: 'A delayed key press from the debugger attempt landed after the fallback.',
|
||
};
|
||
}
|
||
// Anden runde: "OLD" efter fill("NEW") blev kaldt formatering. Tredje og fjerde runde: hver regel for
|
||
// "det er bare formatering" havde huller ("5" -> "15", "1.5" -> "15", Unicode-minus, "+45" -> "+1 45").
|
||
// Femte runde (R5 F1): at melde ENHVER afvigelse som fejl gjorde korrekt formatering ("1.234,50 kr",
|
||
// "+45 12 34 56 78") til ok:false, hvor 1.29.0 sagde ok. Skellet maales nu i stedet for at gaettes:
|
||
// stod feltet stille (foer === efter) -> unknown, og det siges: afviger + uaendret (se nedenfor)
|
||
// aendrede det sig til noget andet -> ok:true, men differs:true med den faktiske vaerdi
|
||
// Kalderen faar altsaa aldrig en tavs succes paa en anden vaerdi end den der blev skrevet.
|
||
// Sign-off 11/9 (Astra og Fable, begge reproduceret): feltet viste allerede "1.234,50 kr", fill("1234.5")
|
||
// blev formateret tilbage til praecis det samme, og svaret var "siden tog ikke imod vaerdien" (1.29.0: ok).
|
||
// Foer = efter kan ikke skelne en afvisning fra en vaerdi der allerede stod der i sidens format.
|
||
// Kun en toemning der ikke skete er entydig: intet format goer "" til noget andet.
|
||
if (typeof foer === 'string' && endelig === foer) {
|
||
if (!v) {
|
||
return {
|
||
ok: false, method: 'fallback', error: 'field-shows-other', expected: v, actual: endelig,
|
||
note: 'The field was meant to be cleared, but shows the same as before.',
|
||
};
|
||
}
|
||
return {
|
||
ok: true, method: 'fallback', value: endelig, differs: true, unchanged: true, expected: v, actual: endelig,
|
||
note: 'The field showed the same before and after the write. Either the value was already there in the page\'s own ' +
|
||
'format, or the page did not accept it. Check `faktisk` before moving on.',
|
||
};
|
||
}
|
||
return {
|
||
ok: true, method: 'fallback', value: endelig, differs: true, expected: v, actual: endelig,
|
||
...(typeof foer === 'string' ? {} : { foer_ukendt: true }),
|
||
note: 'The field changed, but shows different text than what was written - for example formatting ' +
|
||
'("5,00 kr", "+45 12 34 56 78") or a truncation. Check `faktisk` if the exact value matters.',
|
||
};
|
||
}
|
||
return {
|
||
ok: true, method: 'fallback', value: endelig ?? v,
|
||
...(typeof endelig === 'string' ? {} : { verificeret: false }),
|
||
};
|
||
}
|
||
}
|
||
|
||
case 'set_date': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(params.date)) {
|
||
return { ok: false, error: 'date must be ISO format YYYY-MM-DD, got: ' + params.date };
|
||
}
|
||
|
||
const info = await getDateInputInfo(tab.id, params.selector);
|
||
if (!info.found) return { ok: false, error: 'Element not found: ' + params.selector };
|
||
|
||
const tried = [];
|
||
const iso = params.date;
|
||
|
||
// Path A: native <input type="date"> or <input type="datetime-local">
|
||
if (info.tag === 'INPUT' && (info.inputType === 'date' || info.inputType === 'datetime-local')) {
|
||
await setDateNative(tab.id, params.selector, iso);
|
||
await new Promise(r => setTimeout(r, 200));
|
||
const v = await readBackValue(tab.id, params.selector);
|
||
tried.push({ path: 'native', value: v });
|
||
if (v && v.startsWith(iso)) return { ok: true, method: 'native', value: v };
|
||
}
|
||
|
||
// Path B: masked text input — parse format and type via Input.insertText
|
||
if (info.tag === 'INPUT' && !info.readOnly && !info.disabled) {
|
||
const fmt = parsePlaceholderFormat(info.placeholder) || parsePlaceholderFormat(info.ariaLabel);
|
||
if (fmt) {
|
||
try {
|
||
await setDateMaskedTyping(tab.id, params.selector, iso, fmt);
|
||
await new Promise(r => setTimeout(r, 250));
|
||
const v = await readBackValue(tab.id, params.selector);
|
||
tried.push({ path: 'masked', format: fmt.order.join(fmt.sep), value: v });
|
||
if (valueLooksLikeIso(v, iso, fmt)) return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep) };
|
||
} catch (e) {
|
||
// MAALT 10/9 af Astra: en frist her betyder ikke at intet skete. Tastetrykkene kan
|
||
// allerede staa i feltet, og saa ville kalender-vejen nedenfor saette datoen EN GANG
|
||
// TIL. Laes feltet foer vi proever noget andet.
|
||
const v = await readBackValue(tab.id, params.selector).catch(() => null);
|
||
tried.push({ path: 'masked', error: e.message, value: v });
|
||
if (valueLooksLikeIso(v, iso, fmt)) {
|
||
return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep), note: 'landede trods fejl i afsendelsen' };
|
||
}
|
||
}
|
||
} else {
|
||
tried.push({
|
||
path: 'masked',
|
||
skipped: true,
|
||
reason: 'no-parseable-format',
|
||
placeholder: info.placeholder,
|
||
ariaLabel: info.ariaLabel,
|
||
});
|
||
}
|
||
} else {
|
||
tried.push({
|
||
path: 'masked',
|
||
skipped: true,
|
||
reason: info.tag !== 'INPUT' ? 'not-input-element' : (info.readOnly ? 'readonly' : 'disabled'),
|
||
});
|
||
}
|
||
|
||
// Path C: calendar-picker navigation
|
||
if (!params.skip_picker) {
|
||
// Fjerde runde (Astra): kalender-grenen glemte feltets format, saa 01/12/2026 blev godkendt som 12. januar.
|
||
const kendtFormat = parsePlaceholderFormat(info.placeholder) || parsePlaceholderFormat(info.ariaLabel);
|
||
const r = await setDatePicker(tab.id, params.selector, iso);
|
||
await new Promise(r2 => setTimeout(r2, 200));
|
||
const v = await readBackValue(tab.id, params.selector);
|
||
tried.push({ path: 'picker', ...r, value: v });
|
||
if (r.ok && valueLooksLikeIso(v, iso, kendtFormat)) return { ok: true, method: 'picker', value: v, navAttempts: r.navAttempts };
|
||
}
|
||
|
||
const visibleErrors = await collectVisibleErrors(tab.id, params.selector);
|
||
const finalValue = await readBackValue(tab.id, params.selector);
|
||
return {
|
||
ok: false,
|
||
error: 'all-paths-failed',
|
||
tried,
|
||
current_value: finalValue,
|
||
visible_errors: visibleErrors,
|
||
input_info: info,
|
||
};
|
||
}
|
||
|
||
case 'dismiss_overlays': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const scope = params.scope || 'non_critical';
|
||
const maxPasses = params.max_passes ?? 3;
|
||
const r = await dismissOverlays(tab.id, scope, maxPasses);
|
||
return { ok: true, dismissed: r.dismissed, skipped: r.skipped, count: r.dismissed.length };
|
||
}
|
||
|
||
case 'set_combobox': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
if (!params.selector) return { ok: false, error: 'selector required' };
|
||
// FUNDET 13/9 af Astra: vagten var `!params.values && !params.value`, og `![]` er falsk.
|
||
// En tom liste slap igennem, loekken koerte nul gange, og `[].every(...)` er sandt - saa
|
||
// vaerktoejet svarede ok:true uden at have roert siden overhovedet.
|
||
const values = Array.isArray(params.values) ? params.values
|
||
: (params.value !== undefined && params.value !== null && params.value !== '' ? [params.value] : []);
|
||
if (values.length === 0) {
|
||
return { ok: false, error: 'value or values required',
|
||
note: 'Provide at least one value. An empty list is not a performed action.' };
|
||
}
|
||
const r = await setCombobox(tab.id, params.selector, values, {
|
||
multi: !!params.multi,
|
||
query_chars: params.query_chars,
|
||
// MAALT 22/8: wait_ms blev tavst kasseret her. Skemaet lover parameteren, og
|
||
// setCombobox laeser opts.wait_ms — men handleren videregav den ikke, saa
|
||
// ventetiden stod altid paa standarden 3000 ms. En agent der bad om laengere
|
||
// tid til en langsom liste fik den ikke, og fik ingen besked om det.
|
||
wait_ms: params.wait_ms,
|
||
});
|
||
const visibleErrors = r.ok ? [] : await collectVisibleErrors(tab.id, params.selector);
|
||
return r.ok ? r : { ...r, visible_errors: visibleErrors };
|
||
}
|
||
|
||
case 'drop_file': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const files = Array.isArray(params.files) ? params.files
|
||
: [params.files || params.file || params.file_path].filter(Boolean);
|
||
if (!files[0]) return { ok: false, error: 'files or file required' };
|
||
return await dropFileOnTarget(tab.id, params.selector || 'body', files);
|
||
}
|
||
|
||
case 'wait': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const timeout = params.timeout || 10000;
|
||
const sel = params.selector;
|
||
const start = Date.now();
|
||
while (Date.now() - start < timeout) {
|
||
// Text-based selectors use debugger directly
|
||
if (sel.startsWith('text=') || sel.match(/^\w+:text\(/)) {
|
||
const el = await resolveElement(tab.id, sel);
|
||
if (el) return { found: true, method: 'debugger' };
|
||
} else {
|
||
const scriptResult = await safeExecuteScript(tab.id, (s) => !!document.querySelector(s), [sel]);
|
||
if (scriptResult.cspBlocked) {
|
||
const found = await debuggerEval(tab.id, `!!document.querySelector(${JSON.stringify(sel)})`);
|
||
if (found) return { found: true, method: 'debugger' };
|
||
} else if (scriptResult.result) {
|
||
return { found: true };
|
||
}
|
||
}
|
||
await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
return { found: false };
|
||
}
|
||
|
||
case 'press_key': {
|
||
// EKSPERIMENT 21/8: aktiverer IKKE fanen. Kommentaren her sagde at Chrome ellers
|
||
// sender tastetrykket til den aktive fane — det maales nu i stedet for at antages.
|
||
const tab = await getSessionTab(port, false);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const key = params.key; // e.g. "Enter", "Tab", "Escape", "ArrowDown"
|
||
const modifiers = (params.ctrl ? 2 : 0) | (params.alt ? 1 : 0) | (params.shift ? 8 : 0) | (params.meta ? 4 : 0);
|
||
|
||
// v1.22: Chrome requires windowsVirtualKeyCode for navigation/system keys to trigger
|
||
// scroll/form-submit behavior. Without these, key-event is dispatched but page doesn't react.
|
||
const VK_CODES = {
|
||
'Backspace': 8, 'Tab': 9, 'Enter': 13, 'Shift': 16, 'Control': 17, 'Alt': 18,
|
||
'Escape': 27, 'Space': 32, ' ': 32,
|
||
'PageUp': 33, 'PageDown': 34, 'End': 35, 'Home': 36,
|
||
'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40,
|
||
'Delete': 46,
|
||
};
|
||
const vkCode = VK_CODES[key];
|
||
const vkParams = vkCode ? { windowsVirtualKeyCode: vkCode, nativeVirtualKeyCode: vkCode } : {};
|
||
|
||
// MAALT 10/9 af Astra: keyDown og keyUp stod i samme try. Timede keyDown ud — og det
|
||
// kan det, selv naar tasten LANDEDE (Enter der sender en formular) — blev keyUp aldrig
|
||
// sendt. En tast der kun er trykket ned, er en tast der haenger. Nu sendes keyUp altid,
|
||
// og svaret siger om nedtrykket fejlede i stedet for at kaste raat.
|
||
await debuggerAttach(tab.id);
|
||
// Armeres FOER trykket. Fejler injektionen, bliver svaret unknown - aldrig et falskt ja.
|
||
const bevisId = await armerTastBevis(tab.id, key).catch(() => null);
|
||
let tastFejl = null;
|
||
try {
|
||
try {
|
||
await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
|
||
type: 'keyDown',
|
||
key,
|
||
code: params.code || key,
|
||
modifiers,
|
||
text: key.length === 1 ? key : '',
|
||
...vkParams,
|
||
});
|
||
} catch (e) { tastFejl = e; }
|
||
try {
|
||
await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
|
||
type: 'keyUp',
|
||
key,
|
||
code: params.code || key,
|
||
modifiers,
|
||
...vkParams,
|
||
});
|
||
} catch (e) { if (!tastFejl) tastFejl = e; }
|
||
} finally {
|
||
await debuggerDetach(tab.id);
|
||
}
|
||
if (tastFejl) {
|
||
const frist = erCdpFrist(tastFejl);
|
||
return {
|
||
ok: false, key, error: tastFejl.message,
|
||
...(frist ? { maybe_landed: true,
|
||
note: 'Chrome kvitterede ikke inden fristen. Tasten kan alligevel have virket ' +
|
||
'(for example a form that was submitted) - check the page before pressing again.' } : {}),
|
||
};
|
||
}
|
||
// Vaerktoejets egen dom SIDST, som i click: kvitteringen fra Chrome er ikke et bevis.
|
||
const bevis = bevisId ? await laesTastBevis(tab.id, bevisId) : { landed: null };
|
||
if (bevis.landed === true) {
|
||
return { ok: true, key, landed: true,
|
||
...(bevis.navigeret ? { note: 'The page navigated on the key press.' } : {}) };
|
||
}
|
||
if (bevis.landed === null) {
|
||
return { ok: true, key, landed: null, maybe_landed: true,
|
||
note: 'The key was sent, but whether the page received it could not be read. ' +
|
||
'Check the page before pressing again.' };
|
||
}
|
||
return { ok: false, key, landed: false, error: 'key-not-delivered',
|
||
note: 'Chrome acknowledged the keystroke, but no listener in the tab received it. The tab is ' +
|
||
'probably in the background, and Chrome does not deliver mouse or keyboard input to a tab that is not ' +
|
||
'the visible one in its window. Call browser_switch_tab and press again.' };
|
||
}
|
||
|
||
case 'scroll': {
|
||
// v1.22: NO activate — CDP Input.dispatchMouseEvent goes via debugger directly to target,
|
||
// doesn't need active tab. Re-activating on every scroll-call destabilizes debugger.
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
// Scroll to element
|
||
if (params.selector) {
|
||
const el = await resolveElement(tab.id, params.selector);
|
||
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
||
return { ok: true, scrolled_to: params.selector };
|
||
}
|
||
// Scroll by pixels using CDP mouseWheel — split into smaller steps so IntersectionObservers fire.
|
||
// FB/Twitter/IG only trigger lazy-load on continuous wheel events, not a single large delta.
|
||
const dx = params.x || 0;
|
||
const dy = params.y || 0;
|
||
// Hvor stod siden FOER vi roerte den? Uden det tal kan reserveloesningen ikke vide
|
||
// hvor meget hjulet naaede, og ender med at rulle for langt.
|
||
// MAALT 10/9: her stod `.catch(() => ({x:0,y:0}))`. Et opdigtet nulpunkt er vaerre end
|
||
// ingen: stod siden paa 500 og laesningen fejlede, ville reserveloesningen rulle OP.
|
||
const start = await debuggerEval(tab.id, '({x: window.scrollX, y: window.scrollY})')
|
||
.catch(() => null);
|
||
const startKendt = !!start && typeof start.y === 'number';
|
||
const startX = startKendt ? start.x : 0, startY = startKendt ? start.y : 0;
|
||
// MAALT 19/9: et hjul der faldt paa fristen i en baggrundsfane blev leveret SENERE, da
|
||
// fanen kom frem - en ekstra rulning ingen havde bedt om. Reproduceret: 0 -> 300 via
|
||
// reserveloesningen, og efter switch_tab stod siden paa 600 uden et nyt kald.
|
||
// Vi kan ikke afbryde en CDP-kommando vi har opgivet. Men vi kan lade vaere med at sende
|
||
// den: er fanen ikke den aktive i sit vindue, leverer Chrome beviseligt ikke Input.* dertil
|
||
// (maalt 11/9), saa hjulet er spildt uanset. Vi springer det over, ruller aerligt med
|
||
// scrollTo, og siger hvorfor - i stedet for at efterlade et spoegelse i koeen.
|
||
// ⚠️ Kun paa det vi HAR maalt. Om en AKTIV fane i et daekket vindue faar input, ved vi
|
||
// ikke, saa den behandles som foer: send hjulet og lad fristen doemme.
|
||
let springHjulOver = false;
|
||
try {
|
||
const f = await chrome.tabs.get(tab.id);
|
||
springHjulOver = !!f && f.active === false;
|
||
} catch { springHjulOver = false; }
|
||
try {
|
||
// MAALT 19/9: foerste rettelse skrev en GENVEJ her - egen scrollTo, eget svar. To
|
||
// proever fangede den med det samme: den svarede ok:true selv naar rulningen fejlede,
|
||
// og rapporterede den oenskede position frem for den faktiske. En parallel sti ved
|
||
// siden af den aerlige er en ny loegn, ikke en rettelse. Nu kastes der i stedet, saa
|
||
// den reserveloesning der ALLEREDE er bevist aerlig, goer arbejdet.
|
||
if (springHjulOver) {
|
||
throw new Error('the tab is not the active one in its window: Chrome does not deliver wheel events there, ' +
|
||
'and a wheel sent now would land when the tab came forward - as an extra scroll you did not ask for. ' +
|
||
'It was therefore not sent at all. A feed that loads on wheel hears NOTHING here; call browser_switch_tab first.');
|
||
}
|
||
await debuggerAttach(tab.id);
|
||
const STEP_SIZE = 300; // pixels per wheel-event (matches a typical mouse-wheel notch)
|
||
const totalSteps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / STEP_SIZE));
|
||
const stepX = dx / totalSteps;
|
||
const stepY = dy / totalSteps;
|
||
for (let i = 0; i < totalSteps; i++) {
|
||
await cdpSend(tab.id, 'Input.dispatchMouseEvent', {
|
||
type: 'mouseWheel', x: 400, y: 300, deltaX: stepX, deltaY: stepY,
|
||
});
|
||
// Small delay between wheel-events so IntersectionObserver + lazy-load XHRs can fire
|
||
if (i < totalSteps - 1) await new Promise(r => setTimeout(r, 80));
|
||
}
|
||
// After last wheel-event, give FB/Twitter/IG ~600ms to start lazy-load XHRs
|
||
// before any subsequent commands run (caller often scrapes immediately after)
|
||
await new Promise(r => setTimeout(r, 600));
|
||
} catch (e) {
|
||
// MAALT 9/9 af reviewet: her stod `window.scrollBy(dx, dy)` — altsaa "rul det HELE igen".
|
||
// Promise.race afbryder ikke det kald den opgiver, saa hjultrin der allerede virkede
|
||
// bliver liggende. Reproduceret: scroll({y:600}), foerste trin flyttede 300 uden at
|
||
// kvittere, fallbacken lagde 600 oveni = 900 faktisk, 600 rapporteret.
|
||
// scrollTo mod en beregnet MAAL-position er idempotent: har hjulet allerede rullet
|
||
// halvdelen, ruller vi kun resten.
|
||
// MAALT 10/9 af Astra, i MIN egen rettelse fra fire timer foer: her stod
|
||
// `.catch(() => null)` og derefter `ok: true` ubetinget. Fejlede ogsaa
|
||
// reserveloesningen, svarede vaerktoejet succes med nul rullede pixels.
|
||
// Femte gang samme fejlklasse paa én dag — og den her var min.
|
||
// MAALT 10/9 af Astra (anden runde), reproduceret: kunne starten ikke laeses, blev der
|
||
// rullet RELATIVT - og hjultrin der allerede var landet blev lagt oveni (500 -> 1400 ved
|
||
// y:600). Flaget start_ukendt dokumenterede risikoen uden at forhindre den. Uden kendt
|
||
// start findes ingen rulning der kan gentages uden at rulle dobbelt, saa der rulles ikke.
|
||
if (!startKendt) {
|
||
return {
|
||
ok: false, method: 'fallback', error: 'scroll-unknown', start_unknown: true, wheel_error: e.message,
|
||
hint: 'The wheel call did not answer, and the start position could not be read, so the page MAY have ' +
|
||
'scrolled. Read window.scrollY with browser_execute_script, then scroll the remainder.',
|
||
};
|
||
}
|
||
const landede = await debuggerEval(tab.id, `(() => {
|
||
const foer = { x: window.scrollX, y: window.scrollY };
|
||
window.scrollTo(${startX} + ${dx}, ${startY} + ${dy});
|
||
return { foer, efter: { x: window.scrollX, y: window.scrollY } };
|
||
})()`).catch((fejl) => ({ fejl: fejl?.message || String(fejl) }));
|
||
|
||
if (!landede || landede.fejl) {
|
||
return {
|
||
ok: false, method: 'fallback', error: 'scroll-failed',
|
||
wheel_error: e.message,
|
||
fallback_fejl: landede?.fejl || 'the fallback did not answer',
|
||
};
|
||
}
|
||
// MAALT 11/9 af Astra (e2e-review): med blød rulning (scroll-behavior: smooth) naar siden foerst maalet over de naeste
|
||
// billeder. Laest i samme oejeblik blev en rulning der lykkedes meldt som "bunden er maaske naaet" (1.29.0: ok).
|
||
// Positionen laeses igen hvert 100 ms, til maalet er naaet eller siden staar stille - hoejst ca. 1 s.
|
||
// Astra (efterproevning af c826f63): bundet af et ANTAL forsoeg kom en side med langsomme opslag over serverens 30 s
|
||
// (1.29.0: svar efter 3 s). Genlaesningen er bundet af tid.
|
||
const roSlut = Date.now() + 1000;
|
||
while (Date.now() < roSlut && !(landede.efter.x === startX + dx && landede.efter.y === startY + dy)) {
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
const nu = await debuggerEval(tab.id, '({x: window.scrollX, y: window.scrollY})').catch(() => null);
|
||
if (!nu || typeof nu.y !== 'number') break;
|
||
const stille = nu.x === landede.efter.x && nu.y === landede.efter.y;
|
||
landede.efter = nu;
|
||
if (stille) break;
|
||
}
|
||
const flyttede = landede.efter.x !== landede.foer.x || landede.efter.y !== landede.foer.y;
|
||
const alleredeFremme = !flyttede && startKendt &&
|
||
landede.efter.x === startX + dx && landede.efter.y === startY + dy;
|
||
// Astra (efterproevning af cf5b98a): en animation der gik frem, tilbage og foerst naaede maalet efter 1,6 s, fik
|
||
// svaret ok:false "siden flyttede sig ikke" (1.29.0: ok:true). En rulning der ER sendt, meldes aldrig som fiasko.
|
||
// Kan bevaegelsen ikke ses inden for ventetiden, er svaret unknown - samme aerlighed som maaske_landet paa klik.
|
||
return {
|
||
ok: true,
|
||
method: 'fallback', fallback_reason: e.message,
|
||
position: landede.efter, foer: landede.foer,
|
||
...(flyttede || alleredeFremme ? {} : {
|
||
unknown: true,
|
||
note: 'The scroll was sent, but the position was unchanged when we answered: either a smooth scroll is still running, ' +
|
||
'or the bottom has been reached. Read window.scrollY with browser_execute_script if the exact position matters.',
|
||
}),
|
||
};
|
||
}
|
||
// MAALT 13/9 (Astras hul-audit): her stod `ok:true, scrolled:{x:dx,y:dy}` - altsaa det
|
||
// vaerktoejet BAD om, ikke det siden endte paa. Kvitteringen fra hjulet er ikke et bevis;
|
||
// et element der sluger hjulet, en side der allerede er i bunden, eller en baggrundsfane
|
||
// giver samme falske ja. Reservevejen laeser allerede positionen; den gode sti gjorde ikke.
|
||
const slut = await debuggerEval(tab.id, '({x: window.scrollX, y: window.scrollY})').catch(() => null);
|
||
if (!slut || typeof slut.y !== 'number') {
|
||
return { ok: true, scrolled: { x: dx, y: dy }, method: 'mouseWheel-stepped', unknown: true,
|
||
note: 'The scroll was sent, but the position could not be read afterwards. Read window.scrollY ' +
|
||
'with browser_execute_script if the exact position matters.' };
|
||
}
|
||
const rykkede = !startKendt || slut.x !== startX || slut.y !== startY;
|
||
const iMaal = startKendt && slut.x === startX + dx && slut.y === startY + dy;
|
||
return { ok: true, method: 'mouseWheel-stepped', position: slut,
|
||
...(startKendt ? { foer: { x: startX, y: startY } } : {}),
|
||
...(rykkede || iMaal ? {} : {
|
||
unknown: true,
|
||
note: 'The scroll was sent, but the page was in the same place when we answered: either a smooth ' +
|
||
'scroll is still running, the bottom has been reached, or the tab is in the background. Read ' +
|
||
'window.scrollY with browser_execute_script if the position matters.',
|
||
}) };
|
||
}
|
||
|
||
// ── v1.26 "superior" tools ──────────────────────────────────────────────
|
||
// to the MCP server — only lengths and shape booleans. Born from the 2026-07-27
|
||
// Azure-secret night: the agent must be able to move a credential from page to
|
||
// field without the value ever entering the LLM context or transcript.
|
||
|
||
case 'double_click': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const el = await resolveElement(tab.id, params.selector);
|
||
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
||
await debuggerAttach(tab.id);
|
||
const dblBevis = await armerHaendelsesBevis(tab.id, 'dblclick').catch(() => null);
|
||
const { x, y } = el;
|
||
await cdpSend(tab.id, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
||
await new Promise(r => setTimeout(r, 30));
|
||
// Proper dblclick: two press/release pairs with escalating clickCount.
|
||
await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 });
|
||
await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 });
|
||
await new Promise(r => setTimeout(r, 40));
|
||
await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 2 });
|
||
await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 2 });
|
||
const db = dblBevis ? await laesHaendelsesBevis(tab.id, dblBevis) : { landed: null };
|
||
return haendelsesSvar(db, 'double-click-not-delivered',
|
||
{ double_clicked: db.landed === true, tag: el.tag, text: el.text });
|
||
}
|
||
|
||
case 'right_click': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const el = await resolveElement(tab.id, params.selector);
|
||
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
||
await debuggerAttach(tab.id);
|
||
const hoejreBevis = await armerHaendelsesBevis(tab.id, 'contextmenu').catch(() => null);
|
||
const { x, y } = el;
|
||
await cdpSend(tab.id, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
||
await new Promise(r => setTimeout(r, 30));
|
||
await dispatchTaalmodigt(tab.id, { type: 'mousePressed', x, y, button: 'right', buttons: 2, clickCount: 1 });
|
||
await dispatchTaalmodigt(tab.id, { type: 'mouseReleased', x, y, button: 'right', buttons: 0, clickCount: 1 });
|
||
const hb2 = hoejreBevis ? await laesHaendelsesBevis(tab.id, hoejreBevis) : { landed: null };
|
||
const svarH = haendelsesSvar(hb2, 'right-click-not-delivered',
|
||
{ right_clicked: hb2.landed === true, tag: el.tag, text: el.text });
|
||
// Den gamle note gaelder stadig naar haendelsen LANDEDE: Chromes egen menu aabner ikke via CDP.
|
||
if (svarH.landed === true) svarH.note = 'the contextmenu event landed; Chrome\'s own menu does not open through CDP - menus built inside the page (OWA, web apps) do';
|
||
return svarH;
|
||
}
|
||
|
||
case 'click_xy': {
|
||
// Raw coordinate click — the escape hatch for custom widgets whose buttons
|
||
// resist every selector strategy (Azure portal dialogs, KO-bound divs).
|
||
// Coordinates come from the caller's own screenshot analysis.
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
if (typeof params.x !== 'number' || typeof params.y !== 'number') {
|
||
return { ok: false, error: 'x and y (numbers, CSS pixels in viewport) are required' };
|
||
}
|
||
// MAALT 10/9: debuggerClick maaler om klikket landede, men svaret blev smidt vaek og
|
||
// `ok: true` stod hardkodet — samme fejl som `click` havde (issue #19). Samme regel her.
|
||
let klik;
|
||
try {
|
||
klik = await debuggerClick(tab.id, params.x, params.y);
|
||
} catch (e) {
|
||
// Samme regel som click (Astra, tredje runde): er museknappen sendt, kan klikket vaere landet -
|
||
// og en kastet fejl mister markeringen over forbindelsen.
|
||
if (e?.trykSendt) {
|
||
return {
|
||
ok: false, error: e.message, maybe_landed: true, clicked_at: { x: params.x, y: params.y },
|
||
note: 'The mouse click was sent, but the debugger failed afterwards. The click MAY have landed, ' +
|
||
'so it is not repeated. Check the page before clicking again.',
|
||
};
|
||
}
|
||
throw e;
|
||
}
|
||
return {
|
||
clicked_at: { x: params.x, y: params.y },
|
||
...(klik || {}),
|
||
// Samme som click: vaerktoejets egen dom og et unknown klik staar sidst, saa siden ikke kan overskrive dem.
|
||
ok: klikLandede(klik),
|
||
...(uvisVurdering(klik) || {}),
|
||
};
|
||
}
|
||
|
||
case 'reattach_debugger': {
|
||
// Ghost-attach recovery without full extension reload: force detach + fresh attach.
|
||
const tab = await getSessionTab(port);
|
||
try { await chrome.debugger.detach({ tabId: tab.id }); } catch {}
|
||
await new Promise(r => setTimeout(r, 150));
|
||
await debuggerAttach(tab.id);
|
||
return { ok: true, reattached: true, tab_id: tab.id };
|
||
}
|
||
|
||
case 'hover': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const el = await resolveElement(tab.id, params.selector);
|
||
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
||
await debuggerAttach(tab.id);
|
||
// ANTAGET 13/9 af Fable, MAALT 18/9 i flow-spaerren: Blink fyrer `mouseover` KUN naar
|
||
// elementet under markoeren SKIFTER. Hover to gange paa det samme, eller klik og hover
|
||
// saa det samme, og anden gang giver kun `mousemove`. Beviset lyttede kun paa mouseover,
|
||
// saa en helt almindelig raekkefoelge svarede "ikke leveret" - et falsk NEJ, som faar
|
||
// agenten til at skifte fane og proeve igen paa noget der virkede.
|
||
const hoverBevis = await armerHaendelsesBevis(tab.id, ['mouseover', 'mousemove']).catch(() => null);
|
||
try {
|
||
await cdpSend(tab.id, 'Input.dispatchMouseEvent', {
|
||
type: 'mouseMoved', x: el.x, y: el.y,
|
||
});
|
||
// Hold hover for duration (default 500ms) so menus/tooltips appear
|
||
await new Promise(r => setTimeout(r, params.duration || 500));
|
||
} finally {
|
||
await debuggerDetach(tab.id);
|
||
}
|
||
const hb = hoverBevis ? await laesHaendelsesBevis(tab.id, hoverBevis) : { landed: null };
|
||
return haendelsesSvar(hb, 'hover-not-delivered', { tag: el.tag, text: el.text });
|
||
}
|
||
|
||
case 'select_option': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
|
||
// Strategy: handle native <select> and custom dropdowns differently
|
||
const isNativeSelect = await debuggerEval(tab.id, `
|
||
(function() {
|
||
const el = document.querySelector(${JSON.stringify(params.selector)});
|
||
return el?.tagName === 'SELECT';
|
||
})()
|
||
`);
|
||
|
||
// `value` og `label` accepteres som alias for `option`. Uden dem gav et forkert
|
||
// navn "undefined" som soegetekst — og vaerktoejet svarede alligevel ok:true.
|
||
const oensket = params.option ?? params.value ?? params.label;
|
||
if (typeof oensket !== 'string' || !oensket) {
|
||
return { ok: false, error: 'Missing `option` (the text or the value of the choice to select).' };
|
||
}
|
||
|
||
if (isNativeSelect) {
|
||
// MAALT 21/8: her blev resultatet af evalueringen — `return !!opt` — kastet vaek,
|
||
// og handleren svarede ubetinget ok:true. Blev muligheden ikke fundet, skete der
|
||
// INTET, og svaret sagde stadig at det var lykkedes. Samme fejlklasse som klikket
|
||
// der svarede ok:true uden at siden reagerede. Nu laeses svaret, og der laeses
|
||
// TILBAGE fra feltet bagefter, saa "valgt" betyder at vaerdien faktisk staar der.
|
||
// MAALT 8/9 paa forbrugeragenten.dk/penge-tilbage: her laa en anden fejl af samme
|
||
// familie. Vagten laeste `sel.value` SYNKRONT lige efter dispatch og kaldte enhver
|
||
// afvigelse "rullet tilbage". Men et React-styret felt der ARBEJDER ser praecis
|
||
// saadan ud: onChange koerer, komponenten gemmer valget et andet sted og nulstiller
|
||
// sin egen `value`. Vi maalte altsaa succes som fiasko — og sagde ok:false om et
|
||
// valg der faktisk landede (chippen "Norlys Energi ×" stod paa siden bagefter).
|
||
//
|
||
// Det er den omvendte udgave af issue #19, og rettelsen er den samme som issuet
|
||
// beder om: maal EFFEKTEN, ikke feltet. Aendrede resten af siden sig, gjorde
|
||
// komponenten sit arbejde — uanset hvad feltet staar paa nu.
|
||
// FUNDET 17/9 af Hronom paa issue #19: her stod tekstens LAENGDE som bevis. To fejl faldt ud
|
||
// af det. Et accepteret valg hvis label skifter "Alfa" -> "Beta" er lige saa langt, saa
|
||
// aftrykket var identisk og svaret blev "rullet tilbage". Og en urelateret status der gik
|
||
// 9 -> 10 aendrede laengden, saa svaret blev "valget landede". Hash lukker den foerste.
|
||
const aftryk = `(function(el){
|
||
const t = el.form ? el.form.innerText : document.body.innerText;
|
||
let h = 2166136261;
|
||
for (let i = 0; i < t.length; i++) { h ^= t.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||
return el.options.length + '|' + (h >>> 0);
|
||
})(document.querySelector(${JSON.stringify(params.selector)}))`;
|
||
|
||
const valg = await debuggerEval(tab.id, `
|
||
(function() {
|
||
const sel = document.querySelector(${JSON.stringify(params.selector)});
|
||
if (!sel) return JSON.stringify({ found: false, error: 'select ikke fundet' });
|
||
const oensket = ${JSON.stringify(oensket)};
|
||
const opt = Array.from(sel.options).find(o => o.value === oensket)
|
||
|| Array.from(sel.options).find(o => o.text.trim() === oensket)
|
||
|| Array.from(sel.options).find(o => o.text.includes(oensket));
|
||
if (!opt) {
|
||
return JSON.stringify({ found: false, error: 'Ingen mulighed matchede: ' + oensket,
|
||
available: Array.from(sel.options).map(o => o.text.trim()).slice(0, 25) });
|
||
}
|
||
const foer = ${aftryk};
|
||
sel.value = opt.value;
|
||
sel.dispatchEvent(new Event('input', { bubbles: true }));
|
||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return JSON.stringify({ found: true, wanted: opt.value, actual: sel.value, text: opt.text.trim(), foer });
|
||
})()
|
||
`);
|
||
let r; try { r = JSON.parse(valg); } catch { r = null; }
|
||
if (!r) return { ok: false, type: 'native_select', error: 'Could not read the result of the selection' };
|
||
if (!r.found) return { ok: false, type: 'native_select', error: r.error, available: r.available };
|
||
|
||
if (r.actual === r.wanted) return { ok: true, type: 'native_select', selected: r.text, value: r.actual };
|
||
|
||
// Feltet holder ikke vaerdien. Giv rammen tid til at gen-rendere, og se saa efter
|
||
// om NOGET andet aendrede sig. Gjorde det det, blev valget taget imod.
|
||
await new Promise((res) => setTimeout(res, 150));
|
||
const efter = await debuggerEval(tab.id, `
|
||
(function() {
|
||
const sel = document.querySelector(${JSON.stringify(params.selector)});
|
||
if (!sel) return JSON.stringify({ value: null, aftryk: null });
|
||
return JSON.stringify({ value: sel.value, aftryk: ${aftryk} });
|
||
})()
|
||
`);
|
||
let e; try { e = JSON.parse(efter); } catch { e = null; }
|
||
|
||
if (e && e.value === r.wanted) {
|
||
return { ok: true, type: 'native_select', selected: r.text, value: e.value };
|
||
}
|
||
// En bedre hash lukker IKKE det andet hul: en urelateret aendring paa siden ser stadig ud
|
||
// som en reaktion. Derfor er det her det TREDJE udfald og ikke et selvsikkert ja - samme
|
||
// regel som den custom-gren der ligger 40 linjer nede, og samme ordforraad som resten af
|
||
// klassen. Hronom bad selv om praecis det: "a distinct unverified outcome".
|
||
if (e && e.aftryk && r.foer && e.aftryk !== r.foer) {
|
||
const unknown = uvisVurdering({ landed: null, unverified: true },
|
||
'The selection was sent, and the page changed - but the change does not prove it WAS the selection: ' +
|
||
'something else on the page may have moved at the same time. A controlled component can also reset ' +
|
||
'the field and store the selection elsewhere, which is correct behaviour. Read the page instead of selecting again.');
|
||
return {
|
||
ok: true, type: 'native_select', landed: null, selected: r.text, value: e.value, ...unknown,
|
||
note: `The field reset itself to "${e.value}", and the page changed - but the change ` +
|
||
'beviser ikke at det var valget. ' + unknown.note,
|
||
};
|
||
}
|
||
// Kunne aftrykket slet ikke laeses, er det ogsaa unknown. Her stod et haardt "rullet tilbage".
|
||
if (!e || !e.aftryk || !r.foer) {
|
||
const unknown = uvisVurdering({ landed: null, unverified: true });
|
||
return { ok: true, type: 'native_select', landed: null, selected: r.text,
|
||
value: e ? e.value: r.actual, ...unknown };
|
||
}
|
||
return { ok: false, type: 'native_select', landed: false,
|
||
error: `The selection was rolled back: set "${r.wanted}", the field is on "${e ? e.value: r.actual}", ` +
|
||
'and nothing else on the page changed.' };
|
||
}
|
||
|
||
// Custom dropdown (Angular Material, React Select, etc.)
|
||
// Step 1: Click the trigger to open
|
||
const trigger = await resolveElement(tab.id, params.selector);
|
||
if (!trigger) return { ok: false, error: 'Dropdown trigger not found: ' + params.selector };
|
||
await debuggerClick(tab.id, trigger.x, trigger.y);
|
||
|
||
// Step 2: Wait for options to appear
|
||
await new Promise(r => setTimeout(r, params.wait || 300));
|
||
|
||
// Step 3: Find and click the option by text
|
||
const option = await resolveElement(tab.id, `text=${oensket}`);
|
||
if (!option) return { ok: false, error: 'Option not found: ' + oensket };
|
||
const valgKlik = await debuggerClick(tab.id, option.x, option.y);
|
||
|
||
// MAALT 22/8 ved review: aerlighedsfixet blev kun anvendt paa native-grenen
|
||
// ovenfor. Her stod stadig `return { ok: true }` ubetinget, selv om resultatet
|
||
// af klikket var beregnet og smidt vaek — praecis den fejl der blev lukket to
|
||
// gange andre steder samme dag. En brugerdefineret dropdown hvor klikket ikke
|
||
// blev taget imod, meldte altsaa stadig succes.
|
||
// MAALT 22/8 (tredje gang samme fejlklasse): `ok: true` stod hardkodet, og `landed`
|
||
// blev blot spredt ind ved siden af. En dropdown hvor klikket ikke blev taget imod
|
||
// svarede altsaa {ok:true, landed:false} — og agenten laeser ok. Beskrivelsen lover
|
||
// ordret "it never reports success without the field actually changing".
|
||
return {
|
||
...(valgKlik || {}),
|
||
ok: klikLandede(valgKlik),
|
||
type: 'custom_dropdown',
|
||
selected: oensket,
|
||
// MAALT 12/9 af Astra: her stod fejlteksten paa ALT der ikke var bevist landet - ogsaa naar koden lige havde
|
||
// regnet ud at den IKKE ved det. En skarp benaegtelse oven paa en uvished er mindre aerlig end ingen tekst.
|
||
...(uvisVurdering(valgKlik)
|
||
|| (!klikLandede(valgKlik)
|
||
? { error: 'The click on the option was not accepted by the page: ' + oensket }
|
||
: {})),
|
||
};
|
||
}
|
||
|
||
case 'handle_dialog': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const action = params.action === 'dismiss' ? 'dismiss' : 'accept';
|
||
const promptText = params.text || '';
|
||
const vent = params.wait === true; // gammel, blokerende adfaerd
|
||
const levetid = params.timeout || 60000;
|
||
|
||
await debuggerAttach(tab.id);
|
||
await cdpSend(tab.id, 'Page.enable', {});
|
||
afvaebnDialog(tab.id, 'a new arming took over this tab'); // kun én ad gangen
|
||
|
||
let opfyld;
|
||
const svar = new Promise((resolve) => { opfyld = resolve; });
|
||
|
||
const listener = (source, method, eventParams) => {
|
||
if (source.tabId !== tab.id || method !== 'Page.javascriptDialogOpening') return;
|
||
afvaebnDialog(tab.id); // uden grund: vi svarer selv lige nedenfor
|
||
cdpSend(tab.id, 'Page.handleJavaScriptDialog', {
|
||
accept: action === 'accept',
|
||
promptText,
|
||
})
|
||
.then(() => opfyld({ ok: true, dialog_type: eventParams.type, message: eventParams.message, action }))
|
||
.catch((e) => opfyld({ ok: false, error: e.message }));
|
||
};
|
||
|
||
const timer = setTimeout(() => {
|
||
afvaebnDialog(tab.id);
|
||
opfyld({ ok: false, error: `Ingen dialog dukkede op inden for ${levetid} ms` });
|
||
}, levetid);
|
||
|
||
armeredeDialoger.set(tab.id, { listener, timer, action, opfyld });
|
||
dialogLoefter.set(tab.id, svar);
|
||
chrome.debugger.onEvent.addListener(listener);
|
||
|
||
if (vent) return await svar;
|
||
|
||
// Armeret. Debuggeren bliver siddende — frakobler vi her, doer lytteren med den.
|
||
return {
|
||
ok: true,
|
||
armed: true,
|
||
action,
|
||
expires_in_ms: levetid,
|
||
note: 'The next dialog on this tab is handled automatically. Now click whatever opens it.',
|
||
};
|
||
}
|
||
|
||
case 'wait_for_network': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
||
const urlPattern = params.url_pattern || '';
|
||
const timeout = params.timeout || 15000;
|
||
const netFrister = netvaerkFrister();
|
||
const budgetSlut = Date.now() + netFrister.budgetMs;
|
||
|
||
await debuggerAttach(tab.id);
|
||
try {
|
||
await cdpSend(tab.id, 'Network.enable', {});
|
||
|
||
const result = await new Promise((resolve) => {
|
||
const timer = setTimeout(() => {
|
||
chrome.debugger.onEvent.removeListener(listener);
|
||
resolve({ ok: false, error: 'No matching request within timeout' });
|
||
}, timeout);
|
||
|
||
const listener = (source, method, eventParams) => {
|
||
if (source.tabId !== tab.id) return;
|
||
|
||
if (method === 'Network.responseReceived') {
|
||
const url = eventParams.response?.url || '';
|
||
const status = eventParams.response?.status;
|
||
// Match by pattern (substring match) or return any if no pattern
|
||
if (!urlPattern || url.includes(urlPattern)) {
|
||
chrome.debugger.onEvent.removeListener(listener);
|
||
clearTimeout(timer);
|
||
// Try to get response body - kun inden for vaerktoejets budget (netvaerkBudgetMs). Naar den ikke frem, er
|
||
// svaret body:null som i 1.29.0, i stedet for at serverens 30 s loeber ud.
|
||
const bodyKald = cdpSend(tab.id, 'Network.getResponseBody', { requestId: eventParams.requestId });
|
||
bodyKald.catch(() => {});
|
||
let bodyUr;
|
||
Promise.race([
|
||
bodyKald,
|
||
new Promise((ok) => {
|
||
const bodyFrist = Math.min(netFrister.bodyMaxMs, Math.max(netFrister.bodyMinMs, budgetSlut - Date.now()));
|
||
bodyUr = setTimeout(() => ok(null), bodyFrist);
|
||
}),
|
||
]).finally(() => clearTimeout(bodyUr)).then(bodyResult => {
|
||
resolve({
|
||
ok: true,
|
||
url,
|
||
status,
|
||
method: eventParams.response?.requestHeaders?.[':method'] || 'GET',
|
||
body: bodyResult?.body?.substring(0, 5000) || null,
|
||
});
|
||
}).catch(() => {
|
||
resolve({
|
||
ok: true,
|
||
url,
|
||
status,
|
||
method: eventParams.response?.requestHeaders?.[':method'] || 'GET',
|
||
body: null,
|
||
});
|
||
});
|
||
}
|
||
}
|
||
};
|
||
chrome.debugger.onEvent.addListener(listener);
|
||
});
|
||
|
||
await cdpSend(tab.id, 'Network.disable', {});
|
||
return result;
|
||
} finally {
|
||
await debuggerDetach(tab.id);
|
||
}
|
||
}
|
||
|
||
case 'fetch': {
|
||
// HTTP requests from background — NOT subject to CORS
|
||
const options = {
|
||
method: params.method || 'GET',
|
||
headers: params.headers || {},
|
||
};
|
||
if (params.body) options.body = typeof params.body === 'string' ? params.body : JSON.stringify(params.body);
|
||
try {
|
||
const resp = await fetch(params.url, options);
|
||
const text = await resp.text();
|
||
let json = null;
|
||
try { json = JSON.parse(text); } catch {}
|
||
return { ok: resp.ok, status: resp.status, body: json || text };
|
||
} catch (e) {
|
||
return { ok: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
case 'list_tabs': {
|
||
// Return only this session's tabs
|
||
const session = getSession(port);
|
||
const tabs = [];
|
||
for (const tabId of session.tabIds) {
|
||
try {
|
||
const tab = await chrome.tabs.get(tabId);
|
||
tabs.push({ id: tab.id, url: tab.url, title: tab.title, active: tab.active });
|
||
} catch {
|
||
session.tabIds.delete(tabId);
|
||
}
|
||
}
|
||
return { tabs, session: session.label, color: session.color };
|
||
}
|
||
|
||
case 'get_cookies': {
|
||
// MAALT 10/9, reproduceret i selen: uden `domain` blev filteret {} — altsaa INTET
|
||
// filter, altsaa hver eneste cookie i profilen, inklusive httpOnly-sessionscookies
|
||
// som sidens eget JS ikke maa se. Skemaet siger required: ['domain'], men serveren
|
||
// videresender argumenter uvalideret, saa skemaet var en henstilling.
|
||
if (!params.domain || typeof params.domain !== 'string' || !params.domain.trim()) {
|
||
return {
|
||
ok: false,
|
||
error: 'domain-missing',
|
||
hint: 'Angiv `domain`. Uden det ville kaldet returnere HVER cookie i profilen — ' +
|
||
'including from pages that have nothing to do with the task.',
|
||
};
|
||
}
|
||
// MAALT 10/9, to runder. Foerste udgave gaettede domaeneslaegtskab ud fra fanernes
|
||
// vaertsnavne ("a.example.com ligger under com"). Astra omgik det tre veje: en fane paa
|
||
// https://com/ aabnede hele .com, en file://bank.example/-fane gav bankens cookies uden at
|
||
// nogen side var aabnet, og et tomt vaertsnavn (about:blank) lod "bank.example." slippe
|
||
// igennem. Et domaene kan man ikke raesonnere sig til uden public suffix-listen. Saa nu
|
||
// spoerges Chrome i stedet: hvilke cookies ville du SENDE til de http(s)-sider sessionen har
|
||
// aabne? Kun dem - og kun dem der passer paa det domaene der blev bedt om.
|
||
const session = getSession(port);
|
||
// Astra, tredje runde: en inkognito-fane har sit EGET cookie-lager. Uden storeId blev den
|
||
// almindelige profils cookies laest for en inkognito-fane.
|
||
let lagre = null;
|
||
try { lagre = await chrome.cookies.getAllCookieStores(); } catch {}
|
||
const lagerFor = (tabId) => (lagre || []).find((l) => (l.tabIds || []).includes(tabId))?.id;
|
||
const sider = [];
|
||
let ukendtLager = false;
|
||
for (const id of session.tabIds) {
|
||
const t = await chrome.tabs.get(id).catch(() => null);
|
||
try {
|
||
const u = new URL(t?.url || '');
|
||
if ((u.protocol === 'https:' || u.protocol === 'http:') && u.hostname) {
|
||
const storeId = lagerFor(id);
|
||
// Astra (sign-off 11/9): fejlede getAllCookieStores for en inkognitofane, blev storeId udeladt, og Chrome
|
||
// laeste den almindelige profils lager. Kan en inkognitofanes lager ikke findes, laeses intet for den.
|
||
if (t.incognito && !storeId) { ukendtLager = true; continue; }
|
||
// Fanens vaertsnavn er altid ASCII (punycode). Et afsluttende punktum er en ANDEN cookie-vaert i
|
||
// Chromium (Astra, fjerde runde: x.example. fik cookies fra x.example) - saa det bevares.
|
||
sider.push({ u, vaert: u.hostname.toLowerCase(), storeId });
|
||
}
|
||
} catch {}
|
||
}
|
||
if (!sider.length && ukendtLager) {
|
||
return {
|
||
ok: false, error: 'cookie-store-unknown',
|
||
hint: 'The tab is an incognito window, and Chrome did not report its cookie store. Nothing was read - otherwise ' +
|
||
'den almindelige profils cookies blive leveret i stedet.',
|
||
};
|
||
}
|
||
const vaertsnavne = sider.map((x) => x.vaert);
|
||
// Argumentet normaliseres som fanens adresse - "bücher.example" ER xn--bcher-kva.example.
|
||
let d = params.domain.trim().toLowerCase().replace(/^\.+/, '');
|
||
try { if (d) d = new URL('http://' + d + '/').hostname; } catch {}
|
||
const slaegt = (a, b) => a === b || a.endsWith('.' + b) || b.endsWith('.' + a);
|
||
if (!d || !vaertsnavne.some((h) => slaegt(h, d))) {
|
||
return {
|
||
ok: false, error: 'domain-not-in-session', domain: d, aabne: vaertsnavne,
|
||
hint: 'Cookies can only be read for http(s) pages this session has open. Navigate to ' +
|
||
'the page first - then the agent cannot read cookies from anything it is not working with.',
|
||
};
|
||
}
|
||
// Noeglen er et JSON-array, saa "a|b" i sti og navn ikke kan laegge to cookies sammen til én.
|
||
const fundne = new Map();
|
||
const med = (c, storeId) => {
|
||
const cd = String(c.domain || '').toLowerCase().replace(/^\./, '');
|
||
if (slaegt(cd, d)) fundne.set(JSON.stringify([storeId ?? '', c.domain, c.path, c.name]), c);
|
||
};
|
||
for (const side of sider) {
|
||
const lager = side.storeId ? { storeId: side.storeId } : {};
|
||
// Fable (sign-off 11/9): en session paa http:// fik en Secure-cookie fra overdomaenet. {url} udelader dem selv,
|
||
// men opslagene paa {domain} kender ikke sidens protokol. Chrome sender aldrig en Secure-cookie til http.
|
||
// Astra (efterproevning af c1496d4): Chromium regner localhost for sikker og sender Secure-cookies dertil over http.
|
||
const sikkerVaert = side.u.protocol === 'https:' || side.vaert === 'localhost' || side.vaert.endsWith('.localhost') ||
|
||
/^127(\.\d{1,3}){3}$/.test(side.vaert) || side.vaert === '[::1]';
|
||
const sendesOverProtokollen = (c) => !c.secure || sikkerVaert;
|
||
// De cookies Chrome ville SENDE til siden - inklusive overdomaenets ...
|
||
for (const c of await chrome.cookies.getAll({ url: side.u.href, ...lager })) med(c, side.storeId);
|
||
// ... plus sidens EGNE cookies paa alle stier (Path=/api kom ikke med ovenfor). {domain} giver
|
||
// ogsaa underdomaener og, for et public suffix, hele suffixet - saa kun cookies hvis domaene ER
|
||
// vaertsnavnet.
|
||
for (const c of await chrome.cookies.getAll({ domain: side.vaert, ...lager })) {
|
||
if (String(c.domain || '').toLowerCase().replace(/^\./, '') === side.vaert && sendesOverProtokollen(c)) med(c, side.storeId);
|
||
}
|
||
// MAALT 11/9 af Astra (R5 F7): overdomaenets cookie paa en anden sti (Domain=.example.com; Path=/api) kom
|
||
// med i 1.29.0, men ikke her: {url} giver kun sidens egen sti, og {domain: vaert} holdt kun cookies hvis
|
||
// domaene ER vaertsnavnet. Chrome spoerges nu ogsaa om det domaene der blev bedt om, og kun cookies som
|
||
// DENNE vaert ville faa tilsendt paa en eller anden sti beholdes. En host-only-cookie sendes kun til sin
|
||
// egen vaert, saa den skal passe praecist.
|
||
for (const c of await chrome.cookies.getAll({ domain: d, ...lager })) {
|
||
const cd = String(c.domain || '').toLowerCase().replace(/^\./, '');
|
||
const sendesTilVaerten = c.hostOnly ? side.vaert === cd : (side.vaert === cd || side.vaert.endsWith('.' + cd));
|
||
if (sendesTilVaerten && sendesOverProtokollen(c)) med(c, side.storeId);
|
||
}
|
||
}
|
||
return { cookies: [...fundne.values()].map(c => ({ name: c.name, value: c.value, domain: c.domain, path: c.path })) };
|
||
}
|
||
|
||
case 'get_local_storage': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
|
||
const scriptResult = await safeExecuteScript(tab.id, (key) => key ? localStorage.getItem(key) : JSON.stringify(Object.fromEntries(Object.entries(localStorage))), [params.key || null]);
|
||
if (!scriptResult.cspBlocked) {
|
||
return { value: scriptResult.result };
|
||
}
|
||
const expr = params.key
|
||
? `localStorage.getItem(${JSON.stringify(params.key)})`
|
||
: `JSON.stringify(Object.fromEntries(Object.entries(localStorage)))`;
|
||
const value = await debuggerEval(tab.id, expr);
|
||
return { value, method: 'debugger' };
|
||
}
|
||
|
||
case 'set_cookies': {
|
||
// MAALT 11/9 af Opus og Fable (e2e-review): laesningen er begraenset til sessionens egne sider, men skrivningen satte
|
||
// cookies paa ETHVERT domaene - ogsaa sider sessionen aldrig har aabnet (og butikkens begrundelse lovede det modsatte).
|
||
// Samme regel begge veje: kun de http(s)-vaerter sessionen har aabne.
|
||
// MAALT 11/9 (e2e runde 2): slaegtskabet blev laest BEGGE veje, saa en cookie til et uaabnet UNDERdomaene slap
|
||
// igennem (Astra), adressen kom fra kalderen i stedet for sessionens side (Opus), og en inkognitofane skrev i den
|
||
// almindelige profils lager (Opus). Reglen er nu laesningens: cookiens domaene skal vaere vaerten selv eller et
|
||
// overdomaene vaerten faar cookies fra, adressen bygges af sidens egen oprindelse (saa Chrome haandhaever bl.a.
|
||
// public suffix), og lageret foelger fanen.
|
||
const saetSession = getSession(port);
|
||
let saetUkendtLager = false;
|
||
let saetLagre = null;
|
||
try { saetLagre = await chrome.cookies.getAllCookieStores(); } catch {}
|
||
const saetSider = [];
|
||
for (const id of saetSession.tabIds) {
|
||
const t = await chrome.tabs.get(id).catch(() => null);
|
||
try {
|
||
const u = new URL(t?.url || '');
|
||
if ((u.protocol !== 'https:' && u.protocol !== 'http:') || !u.hostname) continue;
|
||
const storeId = (saetLagre || []).find((l) => (l.tabIds || []).includes(id))?.id;
|
||
if (t.incognito && !storeId) { saetUkendtLager = true; continue; }
|
||
saetSider.push({ u, vaert: u.hostname.toLowerCase(), storeId });
|
||
} catch {}
|
||
}
|
||
if (!saetSider.length && saetUkendtLager) {
|
||
return {
|
||
ok: false, error: 'cookie-store-unknown',
|
||
hint: 'The tab is an incognito window, and Chrome did not report its cookie store. Nothing was written - otherwise ' +
|
||
'cookien lande i den almindelige profil i stedet.',
|
||
};
|
||
}
|
||
// Sidens vaert faar cookies fra sig selv og fra sine overdomaener - ikke fra et underdomaene den ikke har aabnet.
|
||
// MAALT 12/9 af Astra: `find` tog den FOERSTE fane der endte paa domaenet, saa en aaben a.example.com vandt over en
|
||
// lige saa aaben example.com - og adressen blev derefter bygget af den forkerte vaert. Den noejagtige vaert vinder nu.
|
||
// Slaegtskabs-stien (sidens vaert faar cookies fra sit overdomaene) kraever at kalderen SELV har sagt `domain`:
|
||
// uden `domain` er cookien host-only, og saa kan oensket ikke opfyldes fra et underdomaene.
|
||
const saetSideFor = (cd, eksplicitDomaene) =>
|
||
saetSider.find((s) => s.vaert === cd)
|
||
|| (eksplicitDomaene ? saetSider.find((s) => s.vaert.endsWith('.' + cd)) : undefined);
|
||
const results = [];
|
||
const cookieList = Array.isArray(params.cookies) ? params.cookies : [params];
|
||
for (const c of cookieList) {
|
||
let cd = String(c.domain || '').trim().toLowerCase().replace(/^\.+/, '');
|
||
if (!cd && c.url) { try { cd = new URL(c.url).hostname.toLowerCase(); } catch {} }
|
||
try { if (cd) cd = new URL('http://' + cd + '/').hostname; } catch {}
|
||
const side = cd ? saetSideFor(cd, Boolean(String(c.domain || '').trim())) : null;
|
||
if (!side) {
|
||
results.push({
|
||
ok: false, name: c.name, error: 'domain-not-in-session', domain: cd || null,
|
||
aabne: saetSider.map((s) => s.vaert),
|
||
hint: 'Cookies can only be set for http(s) pages this session has open, and only for the page\'s own domain ' +
|
||
'or - if you supply domain yourself - a parent domain it receives cookies from. Navigate to the page first.',
|
||
});
|
||
continue;
|
||
}
|
||
try {
|
||
const cookie = await chrome.cookies.set({
|
||
// Adressen bygges af sessionens egen side - saa Chrome selv haandhaever sine domaeneregler (public suffix osv.).
|
||
url: side.u.origin + (typeof c.path === 'string' && c.path.startsWith('/') ? c.path : '/'),
|
||
name: c.name,
|
||
value: c.value,
|
||
domain: c.domain,
|
||
path: c.path || '/',
|
||
secure: c.secure !== false,
|
||
httpOnly: c.httpOnly || false,
|
||
sameSite: c.sameSite || 'lax',
|
||
...(side.storeId ? { storeId: side.storeId } : {}),
|
||
});
|
||
results.push({ ok: true, name: c.name });
|
||
} catch (e) {
|
||
results.push({ ok: false, name: c.name, error: e.message });
|
||
}
|
||
}
|
||
return { results };
|
||
}
|
||
|
||
case 'set_local_storage': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
|
||
const key = params.key;
|
||
const val = params.value;
|
||
const expr = `localStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(val)})`;
|
||
try {
|
||
const scriptResult = await safeExecuteScript(tab.id, (k, v) => { localStorage.setItem(k, v); return { ok: true }; }, [key, val]);
|
||
if (!scriptResult.cspBlocked) return scriptResult.result;
|
||
} catch {}
|
||
await debuggerEval(tab.id, expr);
|
||
return { ok: true, method: 'debugger' };
|
||
}
|
||
|
||
case 'console_logs': {
|
||
const tab = await getSessionTab(port);
|
||
const count = params.count || 50;
|
||
try {
|
||
await debuggerAttach(tab.id);
|
||
await cdpSend(tab.id, 'Runtime.enable');
|
||
// Collect console messages for a brief period
|
||
const logs = [];
|
||
const handler = (source, method, eventParams) => {
|
||
if (source.tabId === tab.id && method === 'Runtime.consoleAPICalled') {
|
||
logs.push({
|
||
type: eventParams.type,
|
||
text: eventParams.args?.map(a => a.value || a.description || '').join(' '),
|
||
timestamp: eventParams.timestamp,
|
||
});
|
||
}
|
||
};
|
||
chrome.debugger.onEvent.addListener(handler);
|
||
// Also grab existing console via page JS
|
||
const { result } = await cdpSend(tab.id, 'Runtime.evaluate', {
|
||
expression: `(() => {
|
||
if (!window.__mcpConsoleLogs) {
|
||
window.__mcpConsoleLogs = [];
|
||
const orig = { log: console.log, warn: console.warn, error: console.error, info: console.info };
|
||
for (const [type, fn] of Object.entries(orig)) {
|
||
console[type] = (...args) => {
|
||
window.__mcpConsoleLogs.push({ type, text: args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '), ts: Date.now() });
|
||
if (window.__mcpConsoleLogs.length > 200) window.__mcpConsoleLogs.shift();
|
||
fn.apply(console, args);
|
||
};
|
||
}
|
||
}
|
||
return JSON.stringify(window.__mcpConsoleLogs.slice(-${count}));
|
||
})()`,
|
||
returnByValue: true,
|
||
});
|
||
chrome.debugger.onEvent.removeListener(handler);
|
||
await debuggerDetach(tab.id);
|
||
const existing = JSON.parse(result.value || '[]');
|
||
return { logs: [...existing, ...logs].slice(-count) };
|
||
} catch (e) {
|
||
try { await debuggerDetach(tab.id); } catch {}
|
||
return { logs: [], error: e.message };
|
||
}
|
||
}
|
||
|
||
case 'ask_user': {
|
||
const tab = await getSessionTab(port, true);
|
||
const timeout = params.timeout || 120000;
|
||
const fields = params.fields || [];
|
||
const hasFields = fields.length > 0;
|
||
const session = getSession(port);
|
||
|
||
// Activate tab + alert badge
|
||
await chrome.tabs.update(tab.id, { active: true });
|
||
chrome.action.setBadgeText({ text: '!' });
|
||
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
|
||
const notifId = 'mcp-ask-' + Date.now();
|
||
chrome.notifications.create(notifId, {
|
||
type: 'basic',
|
||
iconUrl: 'icons/icon-128.png',
|
||
title: `${session.label} - Action Required`,
|
||
message: params.message,
|
||
requireInteraction: true,
|
||
silent: false,
|
||
priority: 2,
|
||
});
|
||
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId: tab.id },
|
||
func: (message, title, fields, hasFields, timeout, sessionLabel) => {
|
||
return new Promise((resolve) => {
|
||
document.getElementById('a360-overlay')?.remove();
|
||
|
||
// Notification sound — short pleasant chime
|
||
try {
|
||
const ctx = new AudioContext();
|
||
const osc = ctx.createOscillator();
|
||
const gain = ctx.createGain();
|
||
osc.connect(gain);
|
||
gain.connect(ctx.destination);
|
||
osc.frequency.value = 880;
|
||
osc.type = 'sine';
|
||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
|
||
osc.start(ctx.currentTime);
|
||
osc.stop(ctx.currentTime + 0.4);
|
||
// Second tone (higher, pleasant ding-dong)
|
||
setTimeout(() => {
|
||
const osc2 = ctx.createOscillator();
|
||
const gain2 = ctx.createGain();
|
||
osc2.connect(gain2);
|
||
gain2.connect(ctx.destination);
|
||
osc2.frequency.value = 1320;
|
||
osc2.type = 'sine';
|
||
gain2.gain.setValueAtTime(0.2, ctx.currentTime);
|
||
gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||
osc2.start(ctx.currentTime);
|
||
osc2.stop(ctx.currentTime + 0.3);
|
||
}, 150);
|
||
} catch {}
|
||
|
||
// Inject animation keyframes
|
||
if (!document.getElementById('a360-styles')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'a360-styles';
|
||
style.textContent = `
|
||
@keyframes a360-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||
@keyframes a360-slide-up { from { opacity: 0; transform: translateY(30px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'a360-overlay';
|
||
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:2147483647;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,sans-serif;animation:a360-fade-in 0.3s ease-out';
|
||
|
||
const card = document.createElement('div');
|
||
card.style.cssText = 'background:#1e293b;border-radius:12px;padding:24px;max-width:420px;width:90%;color:#e2e8f0;box-shadow:0 20px 60px rgba(0,0,0,0.5);animation:a360-slide-up 0.4s ease-out';
|
||
|
||
const h = document.createElement('div');
|
||
h.style.cssText = 'font-size:14px;font-weight:600;color:#3b82f6;margin-bottom:4px';
|
||
h.textContent = title || 'Agent360 - Action Required';
|
||
card.appendChild(h);
|
||
const badge = document.createElement('div');
|
||
badge.style.cssText = 'font-size:10px;color:#94a3b8;margin-bottom:12px';
|
||
badge.textContent = sessionLabel;
|
||
card.appendChild(badge);
|
||
const msg = document.createElement('div');
|
||
msg.style.cssText = 'font-size:13px;color:#cbd5e1;margin-bottom:16px;line-height:1.5';
|
||
msg.textContent = message;
|
||
card.appendChild(msg);
|
||
const inputs = {};
|
||
if (hasFields) {
|
||
fields.forEach(f => {
|
||
const label = document.createElement('label');
|
||
label.style.cssText = 'display:block;font-size:11px;color:#94a3b8;margin-bottom:4px;margin-top:8px';
|
||
label.textContent = f.label || f.name;
|
||
card.appendChild(label);
|
||
const input = document.createElement('input');
|
||
input.type = f.type || 'text';
|
||
input.placeholder = f.label || f.name;
|
||
input.style.cssText = 'width:100%;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none;box-sizing:border-box';
|
||
input.addEventListener('focus', () => input.style.borderColor = '#3b82f6');
|
||
input.addEventListener('blur', () => input.style.borderColor = '#334155');
|
||
card.appendChild(input);
|
||
inputs[f.name] = input;
|
||
});
|
||
}
|
||
const btnRow = document.createElement('div');
|
||
btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px';
|
||
const doneBtn = document.createElement('button');
|
||
doneBtn.textContent = hasFields ? 'Submit' : '✓ Done';
|
||
doneBtn.style.cssText = 'flex:1;padding:10px;background:#3b82f6;color:white;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500';
|
||
doneBtn.addEventListener('click', () => {
|
||
const values = {};
|
||
Object.entries(inputs).forEach(([k, el]) => values[k] = el.value);
|
||
overlay.remove();
|
||
resolve({ acknowledged: true, action: 'done', values });
|
||
});
|
||
const skipBtn = document.createElement('button');
|
||
skipBtn.textContent = '✗ Skip';
|
||
skipBtn.style.cssText = 'flex:1;padding:10px;background:#334155;color:#94a3b8;border:none;border-radius:6px;font-size:13px;cursor:pointer';
|
||
skipBtn.addEventListener('click', () => { overlay.remove(); resolve({ acknowledged: true, action: 'skip', values: {} }); });
|
||
btnRow.appendChild(doneBtn);
|
||
btnRow.appendChild(skipBtn);
|
||
card.appendChild(btnRow);
|
||
overlay.appendChild(card);
|
||
document.body.appendChild(overlay);
|
||
const firstInput = Object.values(inputs)[0];
|
||
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
||
card.addEventListener('keydown', (e) => { if (e.key === 'Enter') doneBtn.click(); });
|
||
setTimeout(() => { if (document.getElementById('a360-overlay')) { overlay.remove(); resolve({ acknowledged: false, action: 'timeout', values: {} }); } }, timeout);
|
||
});
|
||
},
|
||
// MAALT 21/8: her stod `params.title` raat. Skemaet siger at title er VALGFRI
|
||
// med standarden "Agent360 - Action Required", men udelades den, er vaerdien
|
||
// undefined — og chrome.scripting.executeScript afviser hele kaldet med
|
||
// "Error at property 'args': Error at index 1: Value is unserializable".
|
||
// Altsaa styrtede human-in-the-loop-vaerktoejet hver gang en agent fulgte sit
|
||
// eget skema. Det blev aldrig fanget, fordi ask_user stod som "springes over"
|
||
// i flowtesten — den eneste der kunne have set det.
|
||
//
|
||
// Alle argumenter tvinges nu til serialiserbare vaerdier, og standarden
|
||
// anvendes der hvor den er lovet.
|
||
args: [
|
||
String(params.message ?? ''),
|
||
String(params.title ?? 'Agent360 - Action Required'),
|
||
Array.isArray(fields) ? fields : [],
|
||
Boolean(hasFields),
|
||
Number(timeout) || 120000,
|
||
String(session.label ?? 'Claude'),
|
||
],
|
||
world: 'MAIN',
|
||
});
|
||
|
||
// Restore badge
|
||
const count = sessions.size;
|
||
chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
|
||
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
||
chrome.notifications.clear(notifId);
|
||
return result.result;
|
||
}
|
||
|
||
case 'select_frame': {
|
||
const tab = await getSessionTab(port);
|
||
if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
|
||
const frameIndex = params.frame_index ?? 0;
|
||
const frames = await chrome.webNavigation.getAllFrames({ tabId: tab.id });
|
||
if (!frames || frameIndex >= frames.length) {
|
||
return { error: `Frame ${frameIndex} not found. Available: ${frames?.length || 0} frames`, frames: frames?.map((f, i) => ({ index: i, url: f.url })) };
|
||
}
|
||
const frameId = frames[frameIndex].frameId;
|
||
// MAALT 21/8: her stod `func: new Function('return (' + code + ')')`. Den byggede
|
||
// funktionen i SERVICE-WORKEREN, hvor udvidelsens egen CSP forbyder eval — saa
|
||
// vaerktoejet fejlede paa hver eneste side, ogsaa med koden '1+1':
|
||
// "Evaluating a string as JavaScript violates ... 'unsafe-eval' is not allowed".
|
||
// execute_script loeser det samme problem korrekt: send koden med som ARGUMENT og
|
||
// byg funktionen INDE i den injicerede func, hvor sidens egen CSP gaelder. Samme
|
||
// vej her.
|
||
//
|
||
// Og som i execute_script (v1.26): accepter `script` som alias for `code`. Samme
|
||
// navne-uoverensstemmelse har foer faaet vaerktoejer til at se brudte ud i tavshed.
|
||
if (params.code == null && typeof params.script === 'string') params.code = params.script;
|
||
const code = params.code || 'document.body.innerText.slice(0, 5000)';
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId: tab.id, frameIds: [frameId] },
|
||
world: 'MAIN',
|
||
args: [code],
|
||
func: (codeStr) => {
|
||
try {
|
||
return { __ok: true, value: new Function('return (' + codeStr + ')')() };
|
||
} catch (e) {
|
||
return { __scriptingError: true, message: String(e?.message || e) };
|
||
}
|
||
},
|
||
});
|
||
const r = result?.result;
|
||
if (r && r.__scriptingError) {
|
||
return { ok: false, error: r.message, frame_url: frames[frameIndex].url };
|
||
}
|
||
return { result: r?.value, frame_url: frames[frameIndex].url };
|
||
}
|
||
|
||
case 'list_frames': {
|
||
const tab = await getSessionTab(port);
|
||
const frames = await chrome.webNavigation.getAllFrames({ tabId: tab.id });
|
||
return { frames: frames?.map((f, i) => ({ index: i, url: f.url, frame_id: f.frameId, parent_frame_id: f.parentFrameId })) || [] };
|
||
}
|
||
|
||
case 'get_new_tab': {
|
||
if (!lastCreatedTabId) return { error: 'No new tab detected' };
|
||
try {
|
||
const tab = await chrome.tabs.get(lastCreatedTabId);
|
||
// Kun faner der er aabnet FRA en af sessionens egne faner. Uden det her overtog
|
||
// agenten enhver fane brugeren selv havde aabnet — se kommentaren ved onCreated.
|
||
const session = getSession(port);
|
||
const opener = tab.openerTabId ?? openerForFane.get(tab.id) ?? null;
|
||
// Aabneren i sessionen NU, eller sessionen der ejede aabneren da fanen blev oprettet
|
||
// (aabneren kan vaere lukket siden - se ejerForFane ved onCreated).
|
||
const voresNu = opener != null && session.tabIds.has(opener);
|
||
const voresDaDenBlevAabnet = opener != null && ejerForFane.get(tab.id) === port;
|
||
if (!voresNu && !voresDaDenBlevAabnet) {
|
||
return {
|
||
error: 'not-ours',
|
||
hint: 'The most recent new tab was not opened from one of your own tabs, so it ' +
|
||
'belongs to the user. Use browser_navigate(new_tab: true) if you need to ' +
|
||
'have en ny fane.',
|
||
tab_id: tab.id,
|
||
};
|
||
}
|
||
await addTabToSession(port, tab.id);
|
||
return { id: tab.id, url: tab.url, title: tab.title };
|
||
} catch {
|
||
return { error: 'Tab no longer exists' };
|
||
}
|
||
}
|
||
|
||
case 'switch_tab': {
|
||
const session = getSession(port);
|
||
if (!session.tabIds.has(params.tab_id)) {
|
||
throw new Error(`Tab ${params.tab_id} does not belong to this session (${session.label})`);
|
||
}
|
||
const tab = await chrome.tabs.update(params.tab_id, { active: true });
|
||
// Gør ogsaa VINDUET forrest. Uden det bliver fanen aktiv inde i sit vindue —
|
||
// document.hasFocus() bliver sand — men document.visibilityState forbliver
|
||
// 'hidden' fordi vinduet ligger bagved. Chrome struber timere i skjulte
|
||
// faner, saa Angular-apps (Google Ads, GA4, Search Console) renderer aldrig
|
||
// faerdigt: man laeser en halvt bygget side og drager forkerte konklusioner.
|
||
// Kostede to opgaver og en forkert konklusion 31/8-2026.
|
||
let vinduesFokus = null;
|
||
try {
|
||
await chrome.windows.update(tab.windowId, { focused: true });
|
||
vinduesFokus = true;
|
||
} catch (e) {
|
||
// Vinduet kan vaere lukket eller paa et andet Space. Fanen er stadig
|
||
// aktiv; vi siger bare aerligt at synligheden ikke kunne sikres.
|
||
vinduesFokus = false;
|
||
}
|
||
session.activeTabId = tab.id;
|
||
persistSessions();
|
||
return { id: tab.id, url: tab.url, title: tab.title, windowFocused: vinduesFokus };
|
||
}
|
||
|
||
case 'close_tab': {
|
||
const session = getSession(port);
|
||
const tabId = params.tab_id;
|
||
if (!session.tabIds.has(tabId)) {
|
||
throw new Error(`Tab ${tabId} does not belong to this session (${session.label})`);
|
||
}
|
||
// Maerk lukningen som agentens egen. Ellers laeser onRemoved den tomme session som
|
||
// "brugeren er faerdig" og lukker serveren ned midt i samtalen (MAALT 22/8).
|
||
agentLukkedeFaner.add(tabId);
|
||
await chrome.tabs.remove(tabId);
|
||
session.tabIds.delete(tabId);
|
||
if (session.activeTabId === tabId) session.activeTabId = null;
|
||
persistSessions();
|
||
return { ok: true, remaining: session.tabIds.size };
|
||
}
|
||
|
||
case 'solve_captcha': {
|
||
const tab = await getSessionTab(port);
|
||
const action = params.action || 'detect';
|
||
|
||
// ── Detect CAPTCHA on page ──
|
||
if (action === 'detect') {
|
||
const detection = await detectCaptcha(tab.id);
|
||
return detection;
|
||
}
|
||
|
||
// ── Auto-click reCAPTCHA checkbox ──
|
||
if (action === 'click_checkbox') {
|
||
const result = await clickRecaptchaCheckbox(tab.id);
|
||
// Wait for challenge or pass
|
||
await new Promise(r => setTimeout(r, 2500));
|
||
// Re-detect to see if it passed or image challenge appeared
|
||
const after = await detectCaptcha(tab.id);
|
||
return { ...result, after };
|
||
}
|
||
|
||
// ── Click specific grid cells (AI vision guided) ──
|
||
if (action === 'click_grid') {
|
||
const cells = params.cells || [];
|
||
if (!cells.length) return { error: 'No cells specified' };
|
||
const result = await clickCaptchaGridCells(tab.id, cells);
|
||
return result;
|
||
}
|
||
|
||
// ── Human fallback ──
|
||
if (action === 'ask_human') {
|
||
return { method: 'human', instructions: 'Call browser_ask_user with message: "A CAPTCHA needs to be solved. Please solve it in the browser and click Done when finished."' };
|
||
}
|
||
|
||
return { error: 'Unknown action: ' + action };
|
||
}
|
||
|
||
case 'upload_file': {
|
||
const tab = await getSessionTab(port);
|
||
const selector = params.selector || 'input[type="file"]';
|
||
try {
|
||
await debuggerAttach(tab.id);
|
||
// Find the file input element
|
||
const { result: nodeResult } = await cdpSend(tab.id, 'Runtime.evaluate', {
|
||
expression: `(() => {
|
||
const el = document.querySelector(${JSON.stringify(selector)});
|
||
if (!el) return JSON.stringify({ found: false, error: 'File input not found: ' + ${JSON.stringify(selector)} });
|
||
return JSON.stringify({ found: true, tag: el.tagName, type: el.type, accept: el.accept, multiple: el.multiple });
|
||
})()`,
|
||
returnByValue: true,
|
||
});
|
||
const info = JSON.parse(nodeResult.value);
|
||
if (!info.found) {
|
||
await debuggerDetach(tab.id);
|
||
return info;
|
||
}
|
||
|
||
// Get the DOM node ID for the file input.
|
||
//
|
||
// MAALT 21/8: her stod `const { result: docResult } = await cdpSend(...)`.
|
||
// Runtime.evaluate ovenfor svarer {result:{...}}, men DOM.getDocument svarer
|
||
// {root:{...}} — moenstret var kopieret fra det ene kald til det andet. Saa
|
||
// docResult var undefined, og vaerktoejet doede paa
|
||
// "Cannot read properties of undefined (reading 'root')" ved HVERT eneste kald.
|
||
// browser_upload_file kunne ikke uploade en fil paa nogen side overhovedet.
|
||
const docResult = await cdpSend(tab.id, 'DOM.getDocument', {});
|
||
if (!docResult?.root?.nodeId) {
|
||
await debuggerDetach(tab.id);
|
||
return { ok: false, error: 'DOM.getDocument gav intet rod-element' };
|
||
}
|
||
const { nodeId } = await cdpSend(tab.id, 'DOM.querySelector', {
|
||
nodeId: docResult.root.nodeId,
|
||
selector: selector,
|
||
});
|
||
|
||
if (!nodeId) {
|
||
await debuggerDetach(tab.id);
|
||
return { found: false, error: 'Could not get DOM node for file input' };
|
||
}
|
||
|
||
// Set files on the input using CDP.
|
||
// `file_path` accepteres som alias for `file`/`files` — praecis samme navne-faelde
|
||
// som execute_script fik lukket i v1.26. Et forkert navn gav [undefined] og en
|
||
// upload der saa ud til at lykkes.
|
||
const files = Array.isArray(params.files) ? params.files
|
||
: [params.files || params.file || params.file_path].filter(Boolean);
|
||
if (!files.length) {
|
||
await debuggerDetach(tab.id);
|
||
return { ok: false, error: 'Ingen fil angivet. Brug `files` (array) eller `file` (enkelt sti).' };
|
||
}
|
||
await cdpSend(tab.id, 'DOM.setFileInputFiles', {
|
||
nodeId: nodeId,
|
||
files: files,
|
||
});
|
||
|
||
await debuggerDetach(tab.id);
|
||
// Vaerktoejets egen dom SIDST, som i click og press_key: kvitteringen er ikke et bevis.
|
||
const vedhaeftet = await laesVedhaeftedeFiler(tab.id, selector);
|
||
return fildSvar(vedhaeftet, files, { files: files, input: info });
|
||
} catch (e) {
|
||
try { await debuggerDetach(tab.id); } catch {}
|
||
return { ok: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
case 'reload_extension': {
|
||
// MCP server signals that extension files were updated via npx
|
||
// Reload after a short delay to allow response to be sent
|
||
setTimeout(() => chrome.runtime.reload(), 500);
|
||
return { ok: true, message: 'Extension reloading in 500ms' };
|
||
}
|
||
|
||
default:
|
||
throw new Error('Unknown method: ' + method);
|
||
}
|
||
}
|
||
|
||
// ── CAPTCHA Detection & Solving Helpers ─────────────────────────────────────
|
||
|
||
async function detectCaptcha(tabId) {
|
||
try {
|
||
await debuggerAttach(tabId);
|
||
const { result } = await cdpSend(tabId, 'Runtime.evaluate', {
|
||
expression: `(() => {
|
||
const res = { found: false, types: [] };
|
||
|
||
// reCAPTCHA v2 — checkbox iframe
|
||
const recaptchaAnchor = document.querySelector('iframe[src*="recaptcha/api2/anchor"], iframe[src*="recaptcha/enterprise/anchor"]');
|
||
if (recaptchaAnchor) {
|
||
res.found = true;
|
||
res.types.push('recaptcha_v2_checkbox');
|
||
const container = document.querySelector('.g-recaptcha');
|
||
if (container) res.sitekey = container.getAttribute('data-sitekey');
|
||
}
|
||
|
||
// reCAPTCHA v2 — image challenge iframe
|
||
const recaptchaChallenge = document.querySelector('iframe[src*="recaptcha/api2/bframe"], iframe[src*="recaptcha/enterprise/bframe"]');
|
||
if (recaptchaChallenge) {
|
||
res.found = true;
|
||
if (!res.types.includes('recaptcha_v2_checkbox')) res.types.push('recaptcha_v2_image');
|
||
res.types.push('recaptcha_v2_challenge_visible');
|
||
// Get iframe dimensions for grid clicking
|
||
const rect = recaptchaChallenge.getBoundingClientRect();
|
||
res.challengeFrame = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||
}
|
||
|
||
// reCAPTCHA v3 — invisible badge
|
||
const recaptchaV3 = document.querySelector('.grecaptcha-badge');
|
||
if (recaptchaV3 && !recaptchaAnchor) {
|
||
res.found = true;
|
||
res.types.push('recaptcha_v3_invisible');
|
||
res.note = 'reCAPTCHA v3 is invisible and score-based. Real Chrome with Google login usually passes automatically. No action needed.';
|
||
}
|
||
|
||
// hCaptcha
|
||
const hcaptcha = document.querySelector('iframe[src*="hcaptcha.com"], .h-captcha');
|
||
if (hcaptcha) {
|
||
res.found = true;
|
||
res.types.push('hcaptcha');
|
||
const container = document.querySelector('.h-captcha');
|
||
if (container) res.sitekey = container.getAttribute('data-sitekey');
|
||
}
|
||
|
||
// Cloudflare Turnstile
|
||
const turnstile = document.querySelector('iframe[src*="challenges.cloudflare.com"], .cf-turnstile');
|
||
if (turnstile) {
|
||
res.found = true;
|
||
res.types.push('cloudflare_turnstile');
|
||
const container = document.querySelector('.cf-turnstile');
|
||
if (container) res.sitekey = container.getAttribute('data-sitekey');
|
||
}
|
||
|
||
// Cloudflare challenge page (5-second interstitial)
|
||
if (document.title.includes('Just a moment') || document.querySelector('#challenge-running')) {
|
||
res.found = true;
|
||
res.types.push('cloudflare_challenge_page');
|
||
res.note = 'Cloudflare challenge page. Wait 5-10 seconds — real Chrome usually passes automatically.';
|
||
}
|
||
|
||
// FunCaptcha / Arkose Labs
|
||
const funcaptcha = document.querySelector('#FunCaptcha, iframe[src*="funcaptcha"], iframe[src*="arkoselabs"]');
|
||
if (funcaptcha) {
|
||
res.found = true;
|
||
res.types.push('funcaptcha');
|
||
}
|
||
|
||
if (!res.found) res.note = 'No CAPTCHA detected on this page.';
|
||
res.pageUrl = window.location.href;
|
||
return JSON.stringify(res);
|
||
})()`,
|
||
returnByValue: true,
|
||
});
|
||
await debuggerDetach(tabId);
|
||
return JSON.parse(result.value);
|
||
} catch (e) {
|
||
try { await debuggerDetach(tabId); } catch {}
|
||
return { found: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
async function clickRecaptchaCheckbox(tabId) {
|
||
try {
|
||
await debuggerAttach(tabId);
|
||
// Find the reCAPTCHA anchor iframe position
|
||
const { result } = await cdpSend(tabId, 'Runtime.evaluate', {
|
||
expression: `(() => {
|
||
const iframe = document.querySelector('iframe[src*="recaptcha/api2/anchor"], iframe[src*="recaptcha/enterprise/anchor"]');
|
||
if (!iframe) return JSON.stringify({ found: false });
|
||
const rect = iframe.getBoundingClientRect();
|
||
// Checkbox is roughly at 27,30 inside the iframe (standard reCAPTCHA layout)
|
||
return JSON.stringify({ found: true, x: rect.x + 27, y: rect.y + 30 });
|
||
})()`,
|
||
returnByValue: true,
|
||
});
|
||
const pos = JSON.parse(result.value);
|
||
if (!pos.found) {
|
||
await debuggerDetach(tabId);
|
||
return { clicked: false, reason: 'reCAPTCHA checkbox iframe not found' };
|
||
}
|
||
|
||
// Click the checkbox using real mouse events
|
||
await cdpSend(tabId, 'Input.dispatchMouseEvent', {
|
||
type: 'mouseMoved', x: pos.x, y: pos.y,
|
||
});
|
||
await new Promise(r => setTimeout(r, 100 + Math.random() * 200));
|
||
await dispatchTaalmodigt(tabId, {
|
||
type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
|
||
});
|
||
await dispatchTaalmodigt(tabId, {
|
||
type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
|
||
});
|
||
await debuggerDetach(tabId);
|
||
return { clicked: true, position: pos, note: 'Checkbox clicked. Wait 2-3 seconds then re-detect to check if passed or image challenge appeared.' };
|
||
} catch (e) {
|
||
try { await debuggerDetach(tabId); } catch {}
|
||
return { clicked: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
async function clickCaptchaGridCells(tabId, cells) {
|
||
try {
|
||
await debuggerAttach(tabId);
|
||
// Find the challenge iframe position and dimensions
|
||
const { result } = await cdpSend(tabId, 'Runtime.evaluate', {
|
||
expression: `(() => {
|
||
const iframe = document.querySelector('iframe[src*="recaptcha/api2/bframe"], iframe[src*="recaptcha/enterprise/bframe"]');
|
||
if (!iframe) return JSON.stringify({ found: false });
|
||
const rect = iframe.getBoundingClientRect();
|
||
return JSON.stringify({ found: true, x: rect.x, y: rect.y, width: rect.width, height: rect.height });
|
||
})()`,
|
||
returnByValue: true,
|
||
});
|
||
const frame = JSON.parse(result.value);
|
||
if (!frame.found) {
|
||
await debuggerDetach(tabId);
|
||
return { clicked: false, reason: 'Challenge iframe not found. Take a screenshot to verify CAPTCHA state.' };
|
||
}
|
||
|
||
// Determine grid size — reCAPTCHA uses 3x3 or 4x4 grids
|
||
// The image grid starts ~100px from top of iframe, and is roughly square
|
||
const gridTop = frame.y + 100;
|
||
const gridLeft = frame.x + 14;
|
||
const gridSize = frame.width - 28; // padding on each side
|
||
const cols = cells.some(c => c >= 9) ? 4 : 3;
|
||
const rows = cols;
|
||
const cellSize = gridSize / cols;
|
||
|
||
const maxCell = cols * rows - 1;
|
||
const validCells = cells.filter(c => c >= 0 && c <= maxCell);
|
||
if (!validCells.length) {
|
||
await debuggerDetach(tabId);
|
||
return { clicked: false, error: `All cell indices out of bounds. Grid is ${cols}x${rows}, valid range: 0-${maxCell}` };
|
||
}
|
||
|
||
const clicked = [];
|
||
for (const cell of validCells) {
|
||
const row = Math.floor(cell / cols);
|
||
const col = cell % cols;
|
||
const x = Math.round(gridLeft + col * cellSize + cellSize / 2);
|
||
const y = Math.round(gridTop + row * cellSize + cellSize / 2);
|
||
|
||
// Human-like click with small random offset
|
||
const ox = x + Math.round((Math.random() - 0.5) * cellSize * 0.3);
|
||
const oy = y + Math.round((Math.random() - 0.5) * cellSize * 0.3);
|
||
|
||
await cdpSend(tabId, 'Input.dispatchMouseEvent', {
|
||
type: 'mouseMoved', x: ox, y: oy,
|
||
});
|
||
await new Promise(r => setTimeout(r, 150 + Math.random() * 300));
|
||
await dispatchTaalmodigt(tabId, {
|
||
type: 'mousePressed', x: ox, y: oy, button: 'left', clickCount: 1,
|
||
});
|
||
await dispatchTaalmodigt(tabId, {
|
||
type: 'mouseReleased', x: ox, y: oy, button: 'left', clickCount: 1,
|
||
});
|
||
await new Promise(r => setTimeout(r, 200 + Math.random() * 400));
|
||
clicked.push({ cell, row, col, x: ox, y: oy });
|
||
}
|
||
|
||
await debuggerDetach(tabId);
|
||
return {
|
||
clicked: true,
|
||
cells: clicked,
|
||
grid: `${cols}x${rows}`,
|
||
note: 'Cells clicked. Take a screenshot to verify, then click the "Verify" / "Skip" button if needed.',
|
||
};
|
||
} catch (e) {
|
||
try { await debuggerDetach(tabId); } catch {}
|
||
return { clicked: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
// ── Start ───────────────────────────────────────────────────────────────────
|
||
ensureOffscreen().catch(console.error);
|
||
|
||
chrome.runtime.onStartup.addListener(() => ensureOffscreen().catch(console.error));
|
||
// onInstalled fyrer ved installation, opdatering OG ved "Genindlaes" paa
|
||
// chrome://extensions. I alle tre tilfaelde er koden aendret pr. definition, saa en
|
||
// overlevende bro er per definition forældet — uanset hvor villigt den svarer paa ping.
|
||
// MAALT 22/8: uden tvangen slog en genindlaesning aldrig igennem til broen, og
|
||
// udvikling krævede en fuld genstart af Chrome hver gang.
|
||
chrome.runtime.onInstalled.addListener(async () => {
|
||
// Foerst: aeldre udgaver gemte parametre (adgangskoder, cookie-vaerdier) i historikken.
|
||
await rensHandlingslog();
|
||
try {
|
||
if (await chrome.offscreen.hasDocument()) await chrome.offscreen.closeDocument();
|
||
} catch (e) {
|
||
console.warn('[BG] kunne ikke lukke broen ved genindlaesning:', e?.message || e);
|
||
}
|
||
await chrome.storage.local.set({ offscreenGenskabt: 0, offscreenPauseTil: 0 });
|
||
ensureOffscreen().catch(console.error);
|
||
});
|
||
|
||
// Hjerteslag der genskaber offscreen-dokumentet hvis Chrome har ryddet det.
|
||
//
|
||
// FEJL RETTET 19/8: alarmen blev oprettet paa oeverste niveau ved HVER
|
||
// service-worker-opstart. chrome.alarms.create() med et navn der allerede
|
||
// findes NULSTILLER nedtaellingen — saa hvis workeren vaagnede oftere end
|
||
// hvert minut (hvilket den goer ved tab-events, beskeder, navigation),
|
||
// naaede alarmen aldrig at fyre. Resultat: offscreen-dokumentet doede, intet
|
||
// genskabte det, og forbindelsen til MCP-serveren kom aldrig tilbage foer
|
||
// nogen genindlaeste extensionen i haanden.
|
||
//
|
||
// Nu oprettes den kun hvis den ikke findes, saa nedtaellingen faar lov at
|
||
// loebe faerdig.
|
||
chrome.alarms.get('ensure-offscreen', (eksisterende) => {
|
||
if (!eksisterende) {
|
||
chrome.alarms.create('ensure-offscreen', {
|
||
periodInMinutes: 1,
|
||
delayInMinutes: 1,
|
||
});
|
||
}
|
||
});
|
||
|
||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||
if (alarm.name === 'ensure-offscreen') {
|
||
ensureOffscreen().catch(console.error);
|
||
}
|
||
if (alarm.name.startsWith('frigiv-')) {
|
||
const port = Number(alarm.name.slice('frigiv-'.length));
|
||
// ── Hvorfor lageret laeses DIREKTE og ikke via restoreSessions() ─────────
|
||
//
|
||
// FUNDET AF REVIEW 7/9. Her stod `restoreSessions().then(...)`, og kommentaren
|
||
// sagde at det var vaernet mod en genstartet service-worker. Den gjorde det
|
||
// modsatte: `restoreSessions()` gendanner kun sessioner der har GYLDIGE FANER
|
||
// (`if (validTabIds.size > 0)`) — og en tom session er praecis den her alarm
|
||
// handler om. En MV3-worker suspenderes efter ~30 sekunder; fristen er paa fem
|
||
// minutter, saa workeren er naesten altid frisk naar alarmen fyrer. Sessionen
|
||
// blev derfor aldrig fundet, `if (!s) return` ramte, og porten blev holdt til
|
||
// 4-timers-tomgangen — altsaa praecis den fejl frigivelsen skulle fjerne.
|
||
//
|
||
// restoreSessions() har god grund til ikke at genoplive doede sessioner (andre
|
||
// kaldere vil ikke arve faner der ikke findes). Derfor rettes det HER.
|
||
(async () => {
|
||
const iHukommelsen = sessions.get(port);
|
||
if (iHukommelsen) {
|
||
if (iHukommelsen.tabIds.size > 0) return; // den arbejder igen — lad den vaere
|
||
} else {
|
||
const { sessions: gemte } = await chrome.storage.local.get({ sessions: {} });
|
||
const gemt = gemte[String(port)];
|
||
if (!gemt) return; // sessionen er reelt vaek
|
||
if ((gemt.tabIds || []).length > 0) return; // den arbejder igen
|
||
}
|
||
frivilligtFrigivet.add(port);
|
||
chrome.runtime.sendMessage({ type: 'terminate_mcp_session', port }).catch(() => {});
|
||
})().catch(() => {});
|
||
}
|
||
});
|