/** * 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 , Material Web) keep their real //