diff --git a/README.md b/README.md index df769cc..0919298 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,50 @@ 网关内部 spawn 一个 browser-mcp stdio 进程作为上游(自动重连),对外在同一端口暴露两类接口,所有接口均需 Bearer 认证(也支持 `?token=` 查询参数)。 +## 配套组件 + +本项目网关依赖 browser-mcp 的两半:**Chrome 扩展**(驱动浏览器)和 **stdio MCP server**(与扩展配对)。本项目仓库已自带扩展,无需再装 npm 包来获取扩展。 + +### 1. 安装 Chrome 扩展(项目自带 `extension/`) + +1. 打开 Chrome,地址栏输入 `chrome://extensions/` +2. 右上角打开 **开发者模式** +3. 点击 **加载已解压的扩展程序**,选择本项目下的 `extension/` 目录 +4. 点击工具栏扩展图标(Agent360 Browser MCP),确认与本地 server 配对连接(扩展会连接 `127.0.0.1:9876`) + +> `extension/` 来自 npm 包 `@agent360/browser-mcp@1.30.0` 自带的未打包扩展(Manifest V3),升级时可从新版 npm 包重新拷贝。 + +### 2. 安装 stdio 版本 MCP server(本机使用) + +```bash +npm install -g @agent360/browser-mcp +``` + +在 MCP client 的配置中加入(以 Kimi Code 的 `~/.kimi-code/mcp.json` 为例): + +```json +"browser-mcp": { + "command": "node", + "args": ["C:\\Users\\<用户名>\\AppData\\Roaming\\npm\\node_modules\\@agent360\\browser-mcp\\bin\\cli.js"] +} +``` + +Linux / macOS 全局安装后一般为: + +```json +"browser-mcp": { + "command": "node", + "args": ["/usr/local/lib/node_modules/@agent360/browser-mcp/bin/cli.js"] +} +``` + +> 网关(`gateway.js`)内部就是以上述 stdio 方式启动同一个 `cli.js` 作为上游,再由网关桥接到局域网。 + +### 来源与官网 + +- **本项目使用的分叉**:Agent360 Browser MCP —— GitHub: ,npm: `@agent360/browser-mcp` +- **原版 BrowserMCP**:官网 ,GitHub: + ## 接口 | 路径 | 说明 | @@ -90,6 +134,7 @@ claude mcp add --transport http browser-remote http://10.1.1.6:9109/mcp \ ``` gateway.js # 网关主程序:HTTP 服务 + 认证 + stdio 上游管理 + MCP 代理 pages.js # /、/docs、/docs.json 三个页面的 HTML/JSON 渲染 +extension/ # 配套 Chrome 扩展(Agent360 Browser MCP,未打包) package.json # 仅依赖 @modelcontextprotocol/sdk token.txt # Bearer token(gitignore) start-gateway.bat # Windows 启动脚本 diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..acbcadf --- /dev/null +++ b/extension/background.js @@ -0,0 +1,5568 @@ +/** + * 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 + // + + + + + +
Sessions
+
+
No tabs claimed yet
+
+ +
Action Log
+
+
No actions yet
+
+ + + + + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..1bff8b7 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,149 @@ +// ── Status ────────────────────────────────────────────────────────────────── + +// mcpCount = agent clients on the socket. `sessions` = agents that have actually claimed a +// tab. They differ, and showing "Connected - 1 session" above "No active sessions" read as a +// contradiction at exactly the moment a new user checks whether the install worked. +function paintStatus(connected, count, sessionCount) { + document.getElementById('dot').className = `dot ${connected ? 'on' : 'off'}`; + document.getElementById('label').textContent = !connected + ? 'Not connected - nothing on ports 9876-9895 yet' + : sessionCount > 0 + ? `Connected - ${sessionCount} active session${sessionCount === 1 ? '' : 's'}` + : `Connected - ready (${count} agent${count === 1 ? '' : 's'} listening)`; + // The Chrome Web Store can only install the extension. If nothing is listening on + // ports 9876-9895, the user almost certainly never ran the npx install - say so. + document.getElementById('setup').classList.toggle('show', !connected); + // Connected but idle: they got it working and now need to know what to say. + document.getElementById('try').classList.toggle('show', connected && sessionCount === 0); +} + +function refreshStatus() { + chrome.storage.local.get({ mcpConnected: false, mcpCount: 0, sessions: {} }, (result) => { + paintStatus(result.mcpConnected === true, result.mcpCount || 0, + Object.keys(result.sessions || {}).length); + }); +} + +refreshStatus(); + +// ── Sessions with tabs ───────────────────────────────────────────────────── + +function renderSessions() { + chrome.storage.local.get({ sessions: {} }, ({ sessions }) => { + const container = document.getElementById('sessions'); + const entries = Object.entries(sessions); + + if (!entries.length) { + container.innerHTML = '
No tabs claimed yet
'; + return; + } + + // Fetch tab info for each session + const promises = entries.map(async ([port, session]) => { + const tabInfos = []; + for (const tabId of session.tabIds || []) { + try { + const tab = await chrome.tabs.get(tabId); + const url = tab.url || ''; + const display = url.length > 40 ? url.slice(0, 40) + '…' : url; + tabInfos.push(display); + } catch {} + } + return { port, session, tabInfos }; + }); + + Promise.all(promises).then(results => { + container.innerHTML = results.map(({ port, session, tabInfos }) => { + const color = session.color || 'blue'; + const tabHtml = tabInfos.length + ? tabInfos.map(u => `
• ${u}
`).join('') + : '
No tabs
'; + return ` +
+
${session.label} port ${port}
+
${tabHtml}
+
`; + }).join(''); + }); + }); +} + +renderSessions(); + +// ── Action Log ────────────────────────────────────────────────────────────── + +function renderLog() { + chrome.storage.local.get({ actionLog: [] }, ({ actionLog }) => { + const container = document.getElementById('log'); + if (!actionLog.length) { + container.innerHTML = '
No actions yet
'; + return; + } + container.innerHTML = actionLog.slice(0, 30).map(entry => { + const time = new Date(entry.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const cat = entry.category || 'safe'; + const cls = cat === 'sensitive' ? 'log-sensitive' : 'log-safe'; + return `
${time}${entry.method}${entry.session || ''}
`; + }).join(''); + }); +} + +renderLog(); + +// ── Buttons ───────────────────────────────────────────────────────────────── + +document.getElementById('reconnect').addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'reconnect' }); + document.getElementById('label').textContent = 'Reconnecting…'; + setTimeout(() => { + refreshStatus(); + renderSessions(); + }, 3000); +}); + +document.getElementById('copyCmd').addEventListener('click', (e) => { + navigator.clipboard.writeText(document.getElementById('setupCmd').textContent.trim()); + e.target.textContent = 'Copied!'; + setTimeout(() => { e.target.textContent = 'Copy command'; }, 1500); +}); + +document.getElementById('clearLog').addEventListener('click', () => { + chrome.storage.local.set({ actionLog: [] }, renderLog); +}); + +// ── Parring (issue #10) ────────────────────────────────────────────────────── +// Feltet er tomt som standard, og tomt betyder praecis det der sker i dag: ingen noegle, +// ingen opsaetning. Skriver man én, vises den kommando serveren skal startes med - saa +// noeglen ikke skal skrives af i haanden to steder. + +const pairKey = document.getElementById('pairKey'); +const pairCmd = document.getElementById('pairCmd'); +const copyPair = document.getElementById('copyPair'); + +function visPairCmd(noegle) { + const har = Boolean(noegle); + pairCmd.hidden = !har; + copyPair.hidden = !har; + if (har) pairCmd.textContent = `BROWSER_MCP_TOKEN=${noegle} npx @agent360/browser-mcp@latest`; +} + +chrome.storage.local.get('parringsnoegle', (v) => { + const noegle = (v && v.parringsnoegle) || ''; + pairKey.value = noegle; + visPairCmd(noegle); +}); + +document.getElementById('savePair').addEventListener('click', (e) => { + const noegle = pairKey.value.trim(); + chrome.storage.local.set({ parringsnoegle: noegle }, () => { + visPairCmd(noegle); + e.target.textContent = 'Saved'; + setTimeout(() => { e.target.textContent = 'Save'; }, 1500); + }); +}); + +copyPair.addEventListener('click', (e) => { + navigator.clipboard.writeText(pairCmd.textContent.trim()); + e.target.textContent = 'Copied!'; + setTimeout(() => { e.target.textContent = 'Copy the server command'; }, 1500); +});