/** * Nexus Browser Pilot – Background Service Worker * * Der Service Worker ist die einzige Stelle, an der Browser-Tools wirklich laufen. * Alle drei Antriebe münden in denselben Engpass runTool(): * * Broker (Python, WebSocket) ─┐ * MCP-Bridge (WebSocket) ─┼─→ runTool() → Risiko-Gate → dispatchTool() * Eingebauter Loop (agent.ts)─┘ * * Dadurch gilt der Risikomodus für jeden Antrieb, nicht nur für den Chat im Panel. * Der Agent-Loop lebt bewusst hier und nicht im Side Panel: ein geschlossenes Panel * darf einen laufenden Auftrag nicht abbrechen. */ import type { BrokerMessage, ExtHello, ExtToolResult, ExtUserMessage, ExtAbort, ExtApprovalResponse, PanelMessage, RiskVerdict, InteractionMethod, } from '../shared/protocol'; import { loadConfig, patchConfig, EXTENSION_VERSION, RECONNECT_INTERVAL_MS, RECONNECT_MAX_ATTEMPTS, type ExtensionConfig, } from '../shared/config'; import { BROWSER_TOOL_SCHEMAS } from '../shared/tool-schemas.generated'; import * as cdp from './cdp'; import { runComputer } from './computer'; import { gate, setScaleProvider } from './risk'; import { localSave as memoryLocalSave, localSearch as memoryLocalSearch } from './memory'; import { runAgentTurn, abortAgent, isAgentRunning, resetConversation, queueMessage } from './agent'; import type { LlmToolDef } from './llm'; // ─── State ──────────────────────────────────────────────────────────────────── let ws: WebSocket | null = null; let reconnectAttempts = 0; let reconnectTimer: ReturnType | null = null; let sessionSecret = ''; let panelPort: chrome.runtime.Port | null = null; /** * Target-Tab: Der Agent arbeitet auf diesem Tab, unabhängig davon, welcher Tab * gerade im Vordergrund ist. Wird beim ersten Tool-Aufruf auf den aktiven Tab * gesetzt und bleibt dort, bis der Nutzer explizit wechselt (browser_tabs_select) * oder eine neue Konversation startet. */ let targetTabId: number | null = null; /** * Pause zwischen Tool-Aufrufen in ms. Macht den Agent weniger hektisch und gibt * Seiten Zeit zum Rendern. Konfigurierbar über die Config. */ const TOOL_PACE_MS = 600; /** Offene Risiko-Rückfragen: requestId → Auflöser. */ const pendingRisk = new Map void>(); /** * Wartezeit für eine Risiko-Rückfrage. Läuft sie ab, wird AUSGEFÜHRT, nicht abgebrochen. * Der Nutzer hat Autonomie verlangt; Schweigen darf keinen stillen Abbruch bedeuten. * Nur eine ausdrückliche Ablehnung stoppt. */ const RISK_TIMEOUT_MS = 120_000; /** Konsolenpuffer, gefüllt aus dem Content-Script. */ const consoleLogs: Array<{ level: string; text: string; ts: number; url?: string }> = []; const MAX_CONSOLE_LOGS = 300; /** Letzter Screenshot-Maßstab je Tab — für die Koordinaten-Rückrechnung. */ const lastScale = new Map(); // Die Risiko-Engine muss Modell-Koordinaten in CSS-Pixel zurückrechnen können, // bevor sie per elementFromPoint nachsieht, worauf da eigentlich geklickt wird. setScaleProvider((tabId) => lastScale.get(tabId) ?? 1); // ─── Session Secret ─────────────────────────────────────────────────────────── async function getOrCreateSecret(): Promise { const stored = await chrome.storage.session.get(['nexus_secret']); if (stored.nexus_secret) return stored.nexus_secret; const secret = crypto.randomUUID(); await chrome.storage.session.set({ nexus_secret: secret }); return secret; } // ─── WebSocket zum Antrieb (Broker oder MCP-Bridge) ─────────────────────────── async function connect(): Promise { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; const config = await loadConfig(); sessionSecret = await getOrCreateSecret(); try { ws = new WebSocket(config.brokerUrl); } catch (e) { console.error('[Nexus] WS connect error:', e); scheduleReconnect(); return; } ws.onopen = () => { reconnectAttempts = 0; const hello: ExtHello = { type: 'hello', version: EXTENSION_VERSION, secret: sessionSecret }; ws!.send(JSON.stringify(hello)); sendToPanel({ type: 'connection_status', connected: true }); }; ws.onmessage = (event) => { try { handleBrokerMessage(JSON.parse(event.data as string) as BrokerMessage); } catch (e) { console.error('[Nexus] Failed to parse broker message:', e); } }; ws.onclose = () => { ws = null; sendToPanel({ type: 'connection_status', connected: false }); scheduleReconnect(); }; ws.onerror = () => { /* onclose folgt */ }; } function scheduleReconnect(): void { if (reconnectTimer) return; if (reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) { sendToPanel({ type: 'connection_status', connected: false, maxRetries: true }); return; } reconnectAttempts++; const delay = Math.min(RECONNECT_INTERVAL_MS * reconnectAttempts, 30_000); reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); } function sendToBroker(msg: object): void { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); } async function handleBrokerMessage(msg: BrokerMessage): Promise { switch (msg.type) { case 'welcome': case 'text_delta': case 'round': case 'done': case 'error': case 'approval_request': case 'tool_result_event': case 'mcp_status': sendToPanel(msg); break; case 'tool_call': await executeForBroker(msg.callId, msg.name, msg.input); break; case 'ping': sendToBroker({ type: 'pong', ts: msg.ts }); break; } } // ─── Tool-Ausführung ────────────────────────────────────────────────────────── async function executeForBroker(callId: string, name: string, input: Record): Promise { const start = performance.now(); let method: InteractionMethod = 'synthetic'; try { sendToPanel({ type: 'tool_executing', callId, name, input }); const result = await runTool(name, input); method = (result?._method as InteractionMethod) || 'synthetic'; if (result && typeof result === 'object') delete result._method; const durationMs = Math.round(performance.now() - start); sendToBroker({ type: 'tool_result', callId, result, durationMs, method } satisfies ExtToolResult); reportToolDone(callId, name, result, undefined, durationMs, method); } catch (e: unknown) { const error = e instanceof Error ? e.message : String(e); const durationMs = Math.round(performance.now() - start); sendToBroker({ type: 'tool_result', callId, result: null, error, durationMs, method } satisfies ExtToolResult); reportToolDone(callId, name, null, error, durationMs, method); } } /** Ergebnis ans Panel — Bilder gehen getrennt, damit tool_done klein bleibt. */ function reportToolDone( callId: string, name: string, result: any, error: string | undefined, durationMs: number, method: InteractionMethod, ): void { if (result && typeof result === 'object' && typeof result.image === 'string') { sendToPanel({ type: 'screenshot', callId, dataUrl: result.image }); const { image, ...rest } = result; sendToPanel({ type: 'tool_done', callId, name, result: { ...rest, image: '[siehe Vorschau]' }, error, durationMs, method }); return; } sendToPanel({ type: 'tool_done', callId, name, result, error, durationMs, method }); } /** Sendet den aktuellen Target-Tab ans Panel. */ async function notifyTargetTab(): Promise { if (targetTabId === null) { sendToPanel({ type: 'target_tab', tabId: null }); return; } try { const tab = await chrome.tabs.get(targetTabId); sendToPanel({ type: 'target_tab', tabId: targetTabId, title: tab.title, url: tab.url }); } catch { sendToPanel({ type: 'target_tab', tabId: null }); } } /** * DER Engpass. Jeder Antrieb geht hier durch, also greift das Risiko-Gate überall. * Bei riskMode='off' (Standard) kostet das Gate nichts und führt sofort aus. */ async function runTool(name: string, input: Record): Promise { // Gedächtnis braucht keinen Tab und kein Risiko-Gate — es fasst die Seite nicht an. if (name === 'memory_save') { const text = String(input.text ?? '').trim(); if (!text) return errorResult('NO_TEXT', 'text erforderlich', false); const note = await memoryLocalSave( text, String(input.type ?? 'fakt'), Array.isArray(input.tags) ? (input.tags as string[]) : [], ); return note ? { saved: note.id, type: note.type, text: note.text } : errorResult('MEMORY_UNAVAILABLE', 'Der lokale Agent ist nicht erreichbar — die Notiz wurde NICHT gespeichert. ' + 'Arbeite ohne sie weiter.', false); } if (name === 'memory_search') { const notes = await memoryLocalSearch( String(input.query ?? ''), Number(input.limit) || 8, ); return { count: notes.length, notes }; } const tabId = await currentTabId(); const { allowed, verdict } = await gate(tabId, name, input, (v) => askUser(v, name)); if (!allowed) { return { error: { code: 'USER_DECLINED', message: `Vom Nutzer abgelehnt: ${verdict.reason}`, retryable: false, }, _method: 'synthetic', }; } const result = await dispatchTool(name, input); // Pace: kurze Pause nach jeder Aktion, damit Seiten rendern können und der // Agent nicht hektisch wirkt. Screenshots und reine Lese-Tools brauchen keine Pause. const readOnly = name === 'browser_screenshot' || name === 'browser_read_page' || name === 'browser_read_network' || name === 'browser_read_console' || name === 'browser_tabs_list' || name === 'browser_find'; if (!readOnly) { const cfg = await loadConfig(); const pace = cfg.paceMs ?? TOOL_PACE_MS; if (pace > 0) await new Promise(r => setTimeout(r, pace)); } return result; } /** Fragt den Nutzer. Ohne offenes Panel wird gehandelt — Autonomie hat Vorrang. */ function askUser(verdict: RiskVerdict, toolName: string): Promise { if (!panelPort) return Promise.resolve(true); const requestId = crypto.randomUUID(); sendToPanel({ type: 'risk_request', requestId, name: toolName, verdict, timeoutMs: RISK_TIMEOUT_MS }); return new Promise((resolve) => { const timer = setTimeout(() => { pendingRisk.delete(requestId); resolve(true); // Zeitablauf = ausführen, siehe RISK_TIMEOUT_MS }, RISK_TIMEOUT_MS); pendingRisk.set(requestId, (approved) => { clearTimeout(timer); pendingRisk.delete(requestId); resolve(approved); }); }); } // ─── Helfer ─────────────────────────────────────────────────────────────────── async function getActiveTab(): Promise { // Wenn ein Target-Tab gesetzt ist, diesen verwenden (Hintergrundarbeit). if (targetTabId !== null) { try { const tab = await chrome.tabs.get(targetTabId); if (tab && tab.id) return tab; } catch { // Tab existiert nicht mehr — zurückfallen auf aktiven Tab. targetTabId = null; } } const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab || !tab.id) throw new ToolError('NO_ACTIVE_TAB', 'Kein aktiver Tab gefunden', false); // Beim ersten Aufruf den Target-Tab setzen. targetTabId = tab.id!; notifyTargetTab(); return tab; } async function currentTabId(): Promise { if (targetTabId !== null) { try { await chrome.tabs.get(targetTabId); return targetTabId; } catch { targetTabId = null; } } try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); return tab?.id; } catch { return undefined; } } async function execInTab(tabId: number, func: (...args: any[]) => T, args: any[] = []): Promise { const results = await chrome.scripting.executeScript({ target: { tabId }, func: func as any, args }); return results?.[0]?.result as T; } function resolveTarget(input: Record): string | null { if (input.ref_id) return `[data-nexus-ref="${input.ref_id}"]`; if (input.selector) return input.selector as string; return null; } class ToolError extends Error { code: string; retryable: boolean; constructor(code: string, message: string, retryable: boolean) { super(message); this.code = code; this.retryable = retryable; } } function errorResult(code: string, message: string, retryable: boolean): any { return { error: { code, message, retryable }, _method: 'synthetic' }; } /** Liest Position und Kennung eines Elements im Viewport. Magnet-Logik: wenn der * exakte Selektor nichts findet, wird per Textsuche das nächstliegende interaktive * Element gesucht (übernommen aus browser-pilot). */ async function elementBox(tabId: number, selector: string): Promise<{ x: number; y: number; tag: string; text: string } | null> { return await execInTab(tabId, (sel: string) => { let el = document.querySelector(sel) as HTMLElement | null; // Magnet-Fallback: Textsuche auf interaktiven Elementen if (!el) { const searchText = sel.replace(/[^a-zA-Z0-9äöüÄÖÜß ]/g, '').toLowerCase().trim(); if (searchText) { const interactives = Array.from(document.querySelectorAll( 'a, button, input, select, textarea, [role="button"], [onclick], [tabindex]:not([tabindex="-1"]), summary, label' )); for (const candidate of interactives) { const cText = ((candidate as HTMLElement).innerText || (candidate as HTMLInputElement).value || candidate.getAttribute('aria-label') || '').toLowerCase(); if (cText.includes(searchText) || searchText.includes(cText.trim())) { el = candidate as HTMLElement; break; } } } } if (!el) return null; el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' as ScrollBehavior }); const r = el.getBoundingClientRect(); if (r.width === 0 && r.height === 0) return null; return { x: r.x + r.width / 2, y: r.y + r.height / 2, tag: el.tagName.toLowerCase(), text: ((el as HTMLElement).innerText || (el as HTMLInputElement).value || '').trim().slice(0, 80), }; }, [selector]); } /** * Entscheidet je nach inputMode, ob eine echte (CDP) oder synthetische Eingabe erzeugt wird. * 'auto' versucht CDP und fällt bei fehlender Permission oder belegtem Debugger zurück. */ async function wantsTrusted(tabId: number): Promise { const cfg = await loadConfig(); if (cfg.inputMode === 'synthetic') return false; if (!cdp.cdpAvailable()) return false; if (cfg.inputMode === 'trusted') return true; try { await cdp.attach(tabId); return true; } catch { return false; } } // ─── Interaktion ────────────────────────────────────────────────────────────── async function clickElement(tabId: number, selector: string, forceTrusted: boolean): Promise { const trusted = forceTrusted || await wantsTrusted(tabId); if (trusted) { const box = await elementBox(tabId, selector); if (!box) throw new ToolError('ELEMENT_NOT_FOUND', `Element nicht gefunden oder unsichtbar: ${selector}`, true); try { await cdp.attach(tabId); await cdp.mouseClick(tabId, box.x, box.y); notifyCursor(tabId, box.x, box.y, 'left'); return { clicked: selector, tag: box.tag, text: box.text, _method: 'cdp' }; } catch (e: any) { // Kein harter Fehler: lieber synthetisch klicken als gar nicht handeln. console.warn('[Nexus] CDP-Klick fehlgeschlagen, weiche auf synthetisch aus:', e?.message); } } const result = await execInTab(tabId, (sel: string) => { let el = document.querySelector(sel) as HTMLElement | null; // Magnet-Fallback: Textsuche auf interaktiven Elementen if (!el) { const searchText = sel.replace(/[^a-zA-Z0-9äöüÄÖÜß ]/g, '').toLowerCase().trim(); if (searchText) { const interactives = Array.from(document.querySelectorAll( 'a, button, input, select, textarea, [role="button"], [onclick], [tabindex]:not([tabindex="-1"]), summary, label' )); for (const candidate of interactives) { const cText = ((candidate as HTMLElement).innerText || (candidate as HTMLInputElement).value || candidate.getAttribute('aria-label') || '').toLowerCase(); if (cText.includes(searchText) || searchText.includes(cText.trim())) { el = candidate as HTMLElement; break; } } } } if (!el) return { error: 'NOT_FOUND' }; el.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); // Vollständige Maus-Event-Sequenz für maximale Kompatibilität const rect = el.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const eventOpts: MouseEventInit = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: 1, }; el.dispatchEvent(new PointerEvent('pointerover', eventOpts)); el.dispatchEvent(new PointerEvent('pointerenter', { ...eventOpts, bubbles: false })); el.dispatchEvent(new MouseEvent('mouseover', eventOpts)); el.dispatchEvent(new MouseEvent('mouseenter', { ...eventOpts, bubbles: false })); el.dispatchEvent(new PointerEvent('pointerdown', eventOpts)); el.dispatchEvent(new MouseEvent('mousedown', eventOpts)); (el as HTMLElement).focus?.(); el.dispatchEvent(new PointerEvent('pointerup', eventOpts)); el.dispatchEvent(new MouseEvent('mouseup', eventOpts)); el.dispatchEvent(new MouseEvent('click', eventOpts)); return { clicked: sel, tag: el.tagName.toLowerCase(), text: (el.innerText || '').trim().slice(0, 80), coordinates: { x: Math.round(x), y: Math.round(y) } }; }, [selector]); if (!result || (result as any).error === 'NOT_FOUND') { throw new ToolError('ELEMENT_NOT_FOUND', `Element nicht gefunden: ${selector}`, true); } return { ...result, _method: 'synthetic' }; } /** * Riegel gegen versehentliches Absenden von Mails. * * "Nur Entwurf" als Prompt-Regel ist eine Bitte — ein Modell kann sie ignorieren, * und eine verschickte Mail holt niemand zurück. Deshalb hier hart, unabhängig * vom Risikomodus. Greift nur, wenn BEIDES zutrifft: die Schaltfläche heißt nach * Senden, UND die Seite ist erkennbar eine Mail-Oberfläche. Ein "Senden"-Knopf in * einem Kontaktformular bleibt damit bedienbar. * * Grenze, die man kennen muss: browser_computer klickt auf Pixelkoordinaten. Was * dort getroffen wird, ist vorher nicht bekannt — dieser Riegel sieht es nicht. */ async function mailSendBlock(tabId: number, selector: string): Promise { const cfg = await loadConfig(); if (!cfg.blockMailSend) return null; const hit = await execInTab(tabId, (sel: string) => { const el = document.querySelector(sel) as HTMLElement | null; if (!el) return null; const label = [ el.getAttribute('aria-label'), el.getAttribute('title'), (el as HTMLInputElement).value, el.innerText || el.textContent, ].filter(Boolean).join(' ').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 120); // Wortgrenzen: "Absender", "Gesendet" oder "Nachsenden" sind keine Sende-Knöpfe. const isSend = /(^|\s)(senden|send|absenden|abschicken|versenden|jetzt senden|send now)(\s|$)/.test(label); if (!isSend) return null; const host = location.hostname.toLowerCase(); const mailHost = /(outlook|office|mail|owa|gmail|zimbra|roundcube)/.test(host) || /(outlook|owa|mail)/.test(location.pathname.toLowerCase()); // Oder ein erkennbares Verfassen-Formular auf der Seite. const compose = !!document.querySelector( '[aria-label*="Nachrichtentext" i], [aria-label*="message body" i], ' + '[aria-label*="Betreff" i], [aria-label*="subject" i], [name="subject"]', ); if (!mailHost && !compose) return null; return { label, host }; }, [selector]); if (!hit) return null; return errorResult( 'MAIL_SEND_BLOCKED', `Absenden ist gesperrt: "${hit.label}" auf ${hit.host}. Du legst nur Entwürfe an — ` + 'das Verschicken macht der Nutzer selbst. Speichere den Entwurf und melde, dass er ' + 'bereitliegt. (Abschaltbar in den Einstellungen: "Senden blockieren".)', false, ); } /** * Beschreibt, WO der Text wirklich gelandet ist — gemeintes und tatsächlich * fokussiertes Element im Vergleich. * * Der CDP-Pfad fokussiert über einen Klick auf die zuvor gemessene Box. Rendert * die Seite zwischen Messung und Klick neu (in SPAs der Normalfall), landet der * Klick woanders und der Text im falschen Feld. Die frühere Rücklese-Prüfung las * den Wert des GEMEINTEN Elements und meldete deshalb Erfolg, während der Text * im Betreff stand. Genau dieser blinde Fleck hat in Outlook ganze Mails in die * Betreffzeile geschrieben. */ async function describeTypeTarget(tabId: number, selector: string): Promise { return await execInTab(tabId, (sel: string) => { const describe = (el: any) => { if (!el || !el.tagName) return null; // aria-labelledby zuerst: in Outlook Web wird der Nachrichtentext genau so // beschriftet, und ohne diese Auflösung sieht er aus wie das Betreff-Feld. let label = el.getAttribute?.('aria-label') || ''; const by = el.getAttribute?.('aria-labelledby'); if (!label && by) { label = by.split(/\s+/) .map((id: string) => document.getElementById(id)?.textContent || '') .join(' ').trim(); } if (!label) { label = (el.labels?.[0]?.textContent || el.getAttribute?.('placeholder') || el.getAttribute?.('name') || '').trim(); } return { ref: el.getAttribute?.('data-nexus-ref') || undefined, tag: String(el.tagName).toLowerCase(), type: el.type || undefined, role: el.getAttribute?.('role') || undefined, label: label.slice(0, 80) || undefined, editable: el.isContentEditable || undefined, value: String(el.value ?? el.textContent ?? '').slice(0, 200), }; }; const intended = document.querySelector(sel); const active = document.activeElement; return { intended: describe(intended), active: describe(active), same: !!intended && intended === active }; }, [selector]); } /** Ergebnis einer Eingabe, das den Fehlgriff sichtbar macht statt ihn zu verschlucken. */ function typeResult(text: string, selector: string, check: any, submit: boolean, method: string): any { const landed = check?.active ?? check?.intended ?? null; const out: any = { typed: text.length, selector, typed_into: landed, value_after: landed?.value ?? '', submitted: submit, _method: method, }; if (check && !check.same) { // Laut und maschinenlesbar: das Modell soll das korrigieren, nicht weiterschreiben. out.target_mismatch = true; out.intended_target = check.intended; out.warning = 'Der Text ist NICHT im gemeinten Element gelandet, sondern in ' + `"${landed?.label || landed?.tag || 'unbekannt'}". Inhalt dort prüfen, ggf. leeren ` + 'und mit dem richtigen ref_id erneut tippen.'; } return out; } async function typeInElement( tabId: number, selector: string, text: string, clear: boolean, submit: boolean, ): Promise { const trusted = await wantsTrusted(tabId); if (trusted) { const box = await elementBox(tabId, selector); if (!box) throw new ToolError('ELEMENT_NOT_FOUND', `Element nicht gefunden: ${selector}`, true); try { await cdp.attach(tabId); // Fokus über einen echten Klick — so verhält es sich wie bei einem Menschen. await cdp.mouseClick(tabId, box.x, box.y); notifyCursor(tabId, box.x, box.y, 'left'); if (clear) { await cdp.pressKey(tabId, 'a', { modifiers: ['ctrl'] }); await cdp.pressKey(tabId, 'Delete'); } await cdp.typeText(tabId, text); // VOR dem Absenden prüfen: Enter verschiebt den Fokus oft legitim, danach // wäre der Vergleich wertlos. const check = await describeTypeTarget(tabId, selector); if (submit) await cdp.pressKey(tabId, 'Enter'); return typeResult(text, selector, check, submit, 'cdp'); } catch (e: any) { console.warn('[Nexus] CDP-Eingabe fehlgeschlagen, weiche auf synthetisch aus:', e?.message); } } const result = await execInTab(tabId, (sel: string, txt: string, clr: boolean, sub: boolean) => { const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null; if (!el) return { error: 'NOT_FOUND' }; el.focus(); // Der native Setter muss zum konkreten Elementtyp passen. Nimmt man immer den von // HTMLInputElement, wirft .call() auf ein