diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6858b2a..31bb9908f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The independently published `fsb-mcp-server` npm package keeps its own semver ch ### Fixed +- **The bridge no longer dies until you restart Chrome.** The local bridge keeps a single extension socket, and it was freed only when that socket closed itself. An extension whose service worker died without the socket closing therefore held the slot indefinitely, and every legitimate reconnect was refused — one episode in the field ran 13,328 refusals over roughly 25 hours, ending only when the browser quit. The extension could not learn from any of it: its `onclose` handler discarded the close code, and because the WebSocket upgrade *succeeded* before the refusal arrived, `onopen` reset the reconnect backoff on every cycle, so it retried at a flat two seconds forever. The bridge now reaps a socket that stops answering both protocol pings and its own heartbeat; the extension reads the close code, escalates its backoff toward the 30-second ceiling instead of flattening it, and drops a pairing credential the bridge has explicitly rejected rather than replaying it until the browser exits. +- **Keepalive actually detects a dead peer now.** The extension sent a ping every 25 seconds and never looked at the reply; the reply itself was discarded by a handler that only accepted the delegation heartbeat's three-key form. Neither end could notice a half-open socket. Unanswered pings are now counted and close the socket, which hands it to the existing reconnect path. +- **Servers exit when their host does.** `fsb-mcp-server` listened only for SIGTERM and SIGINT, so a host that exits by closing the pipe left a fully working server running forever. Because the bridge port is claimed once by a bind race and never re-attempted, the bridge owner drifted into being the oldest surviving orphan rather than a process anyone was still talking to. The server now shuts down when its stdio pipe closes. +- **A pairing mismatch says so.** An extension id that does not match the pinned pairing was refused with a bare HTTP 403 — no WebSocket, no close code, no reason — which is why the failure so often ended in a reinstall that could not fix it. The refusal now names both origins and the `pair --reset` cure, and `doctor` reports an authorization layer instead of blaming the browser. +- **The bridge keeps a journal.** Refusals, revocations, replacements, reaps, and closes are recorded in `~/.fsb/agent-runtime/bridge-events.jsonl`, coalesced so a retry loop leaves one line a minute rather than tens of thousands. Previously every bridge diagnostic went to the stderr of whichever server won the port race, which was frequently a session that had already exited. +- **`sw_evicted` no longer hides the real cause.** Authorization failures were reported to callers as a service-worker eviction, sending everyone to restart the browser. The recovery is unchanged — the task really was interrupted — but the response now carries a `disconnect_reason` that distinguishes a revoked credential from an evicted worker. + - **Dashboard QR pairing failures are visible again.** Scanning a pairing code that was expired, already used, or otherwise rejected reported nothing at all: the "Connecting..." state replaced the scan panel markup, so the error was written into a node that had already been removed from the DOM, and the dashboard then switched to the Paste Key tab. Every failure now renders its localized reason, keeps the user on the Scan tab, and restarts the camera so a regenerated code can be scanned directly. ## v0.9.91 — Version Metadata Alignment — 2026-07-14 diff --git a/README.md b/README.md index 910b2185c..6ab9ff1c5 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,8 @@ Start with simple tasks such as: Reload the extension from `chrome://extensions/` after local code changes. Reload open tabs after the extension reloads so content scripts re-inject. +For an unpacked build, record the ID on `chrome://extensions/`. A different install path or profile can produce a different ID; if `doctor` reports `ORIGIN_PIN_MISMATCH`, run `npx -y fsb-mcp-server@latest pair --reset` and pair the new installation. One browser profile attaches to the local bridge at a time. `doctor` and `status` show the attached extension ID, version, install instance, connection time, and normal-window count. The MCP server and extension have independent versions. Branded Chrome 137 removed the `--load-extension` flag; use **Load unpacked** in Chrome, or use [Chrome for Testing or Chromium when a command-line flag is required](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ). + ### First Run Checklist 1. Open the FSB control panel from the extension. @@ -287,6 +289,8 @@ Optional Streamable HTTP mode exposes: http://127.0.0.1:7226/mcp ``` +The HTTP endpoint binds to loopback only and rejects requests with an `Origin` header or a foreign `Host`. Use the printed local endpoint from an MCP client. If a tab stops responding, page reads return `PAGE_UNRESPONSIVE`; `navigate` and `close_tab` remain available. Inspect page state before repeating any action reported as `outcome: "unknown"` and `mayHaveExecuted: true`. + ### One Command Install ```bash diff --git a/extension/README.md b/extension/README.md index fa55b8f6f..3bfbb282e 100644 --- a/extension/README.md +++ b/extension/README.md @@ -13,6 +13,10 @@ After code changes, reload the extension from `chrome://extensions` and refresh any open tabs so content scripts re-inject. +Copy the extension ID shown on `chrome://extensions` when pairing an unpacked build. Its ID can differ from the Chrome Web Store ID and can change if you reinstall it from another directory. Run `npx -y fsb-mcp-server@latest pair --reset` if `doctor` reports `ORIGIN_PIN_MISMATCH`, then pair the new installation. `doctor` also shows a persistent install instance ID, the extension version, and the normal-window count so you can identify which browser profile holds the bridge. One extension/profile attaches to the local bridge at a time; another profile may replace it. The MCP server and extension have independent version numbers. + +In branded Chrome 137 and later, the `--load-extension` command-line flag no longer loads unpacked extensions. Use the **Load unpacked** button above. [Chrome for Testing and Chromium retain the flag](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ). + ## Google Sheets Development Google Sheets capabilities reuse an already signed-in, agent-owned spreadsheet tab. They require no Google Cloud client ID, consent prompt, token setup, or Sheets-specific MCP update. Reload the unpacked extension after changes, refresh the open Sheet, and reconnect the existing MCP bridge. See [Google Sheets signed-in session integration](../docs/google-sheets-api.md) for the bounded operation contract and UAT gates. diff --git a/extension/ai/agent-loop.js b/extension/ai/agent-loop.js index 540ed2f5d..0cea8be28 100644 --- a/extension/ai/agent-loop.js +++ b/extension/ai/agent-loop.js @@ -2424,8 +2424,17 @@ async function runAgentIteration(sessionId, options) { ? getGuideForTask('', 'https://' + domain) : null; if (guide) { + var guideGuidance = JSON.stringify({ + selectors: guide.selectors || {}, + workflows: { + createPost: guide.workflows && guide.workflows.createPost, + replyToPost: guide.workflows && guide.workflows.replyToPost + }, + warnings: Array.isArray(guide.warnings) ? guide.warnings.slice(0, 6) : [], + guidance: typeof guide.guidance === 'string' ? guide.guidance.slice(0, 1600) : '' + }).slice(0, 5000); result = { success: true, hadEffect: false, error: null, navigationTriggered: false, - result: { domain: domain, site: guide.site || guide.name || domain, guidance: JSON.stringify(guide.selectors || guide) } }; + result: { domain: domain, site: guide.site || guide.name || domain, guidance: guideGuidance } }; } else { result = { success: true, hadEffect: false, error: null, navigationTriggered: false, result: { domain: domain, guidance: 'No site guide available for ' + domain + '. Use get_page_snapshot and get_dom_snapshot to discover elements.' } }; diff --git a/extension/ai/tool-definitions.js b/extension/ai/tool-definitions.js index b37965e00..6516d1e81 100644 --- a/extension/ai/tool-definitions.js +++ b/extension/ai/tool-definitions.js @@ -242,12 +242,13 @@ const TOOL_REGISTRY = [ withVisualSessionFields({ name: 'type_text', - description: 'Type text into an input field by selector. When to use: to fill text inputs, search boxes, or text areas. Use clear_input first if the field already has text. Returns confirmation of typed text. Related: clear_input (clear field before typing), press_enter (submit after typing), get_dom_snapshot (find input selectors). Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64). Pass tab_id only when this agent owns multiple tabs; auto-resolves otherwise.', + description: 'Replace text in an editable field by selector. Set clear_first to false to append. Returns the rendered text after insertion. Related: clear_input, press_enter, get_dom_snapshot. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { selector: { type: 'string', description: 'CSS selector or element ref for the input field (e.g., "#email", "input[name=search]", or "e12" from get_dom_snapshot)' }, text: { type: 'string', description: 'Text to type into the field' }, + clear_first: { type: 'boolean', description: 'Replace existing text (default true); false appends at the end.' }, tab_id: { type: 'number', description: 'Optional. Tab id this action targets. Omit when the calling agent owns exactly one tab; pass to disambiguate when the agent owns multiple. Single-tab agents and legacy popup/sidepanel/autopilot do not need to pass this.' } }, required: ['selector', 'text'] @@ -869,11 +870,13 @@ const TOOL_REGISTRY = [ withVisualSessionFields({ name: 'insert_text', - description: 'Insert text at the current cursor position via CDP Input.insertText. Bypasses DOM event dispatch and directly inserts into the focused element. When to use: for canvas-based editors (Excalidraw, Google Docs, Slack) where type_text does not work because there is no real input element. The element must already be focused or in edit mode (use double_click_at or click_at first). Related: type_text (for real DOM input fields), double_click_at (enter edit mode in canvas editors before inserting text), click_at (focus canvas element before inserting). Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64). Pass tab_id only when this agent owns multiple tabs; auto-resolves otherwise.', + description: 'Insert text through CDP at the caret by default. Use position end to append or replace_all to replace an editable field; selector can identify that field. Canvas editors require focus first. Related: type_text, double_click_at, click_at. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to insert at current cursor position via CDP' }, + position: { type: 'string', enum: ['caret', 'end', 'replace_all'], description: 'Insertion position; defaults to caret.' }, + selector: { type: 'string', description: 'Optional CSS selector identifying exactly one editable field.' }, tab_id: { type: 'number', description: 'Optional. Tab id this action targets. Omit when the calling agent owns exactly one tab; pass to disambiguate when the agent owns multiple. Single-tab agents and legacy popup/sidepanel/autopilot do not need to pass this.' } }, required: ['text'] @@ -1459,6 +1462,13 @@ function getToolByName(name) { return TOOL_REGISTRY.find(t => t.name === name) || null; } +/** Resolve the public MCP name or the content/CDP verb sent on the bridge. */ +function getToolByNameOrVerb(nameOrVerb) { + const name = typeof nameOrVerb === 'string' ? nameOrVerb.trim() : ''; + if (!name) return null; + return getToolByName(name) || getToolByName(_iconVerbMap().get(name)); +} + /** * Get all read-only tools (those that bypass the mutation queue). * @returns {ToolDefinition[]} Array of read-only tool definitions @@ -1518,7 +1528,7 @@ function resolveIconActivity(nameOrVerb) { if (!raw) return 'sweep'; const name = _iconVerbMap().get(raw) || raw; if (name === 'invoke_capability') return null; - const def = getToolByName(name); + const def = getToolByNameOrVerb(raw); if ((def && def._readOnly === true) || ICON_READ_ONLY_EXTRAS.has(name)) return 'orbit'; return 'sweep'; } @@ -1533,6 +1543,7 @@ if (typeof module !== 'undefined' && module.exports) { TOOL_REGISTRY, resolveIconActivity, getToolByName, + getToolByNameOrVerb, getReadOnlyTools, getToolsByRoute, VISUAL_SESSION_FIELDS, diff --git a/extension/background.js b/extension/background.js index 773ed43fa..307bb92dd 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1149,12 +1149,15 @@ async function sendSessionStatus(tabId, statusData) { ...(statusData?.sessionId ? { sessionId: statusData.sessionId } : {}) }; try { - await chrome.tabs.sendMessage(tabId, payload, { frameId: 0 }); + await Promise.race([chrome.tabs.sendMessage(tabId, payload, { frameId: 0 }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Status probe timed out')), 750))]); } catch (firstErr) { // First attempt failed -- try re-injecting the content script and retry once try { - await ensureContentScriptInjected(tabId, 1); - await chrome.tabs.sendMessage(tabId, payload, { frameId: 0 }); + await Promise.race([ensureContentScriptInjected(tabId, 1), + new Promise((_, reject) => setTimeout(() => reject(new Error('Status reinjection timed out')), 1000))]); + await Promise.race([chrome.tabs.sendMessage(tabId, payload, { frameId: 0 }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Status retry timed out')), 750))]); } catch (retryErr) { automationLogger.debug('sendSessionStatus delivery failed', { tabId, phase: statusData.phase, error: retryErr.message @@ -5329,6 +5332,7 @@ chrome.runtime.onConnect.addListener((port) => { }); port.onDisconnect.addListener(() => { + if (contentScriptPorts.get(tabId)?.port !== port) return; contentScriptPorts.delete(tabId); contentScriptReadyStatus.delete(tabId); contentScriptHealth.delete(tabId); @@ -5842,6 +5846,30 @@ function getContentScriptDiagnosticsForTab(tabId, activeTabUrl = '') { }; } +let mcpInstallInstanceIdPromise = null; +async function getMcpAttachmentMetadata() { + if (!mcpInstallInstanceIdPromise) { + mcpInstallInstanceIdPromise = (async () => { + const key = 'mcpInstallInstanceId'; + const stored = await chrome.storage.local.get(key); + if (typeof stored[key] === 'string' && stored[key]) return stored[key]; + const id = crypto.randomUUID(); + await chrome.storage.local.set({ [key]: id }); + return id; + })().catch((error) => { mcpInstallInstanceIdPromise = null; throw error; }); + } + const [installInstanceId, windows] = await Promise.all([ + mcpInstallInstanceIdPromise, + chrome.windows.getAll({ windowTypes: ['normal'] }) + ]); + return { + extensionId: chrome.runtime.id, + extensionVersion: chrome.runtime.getManifest().version, + installInstanceId, + normalWindowCount: windows.length + }; +} + async function collectMcpDiagnosticsSnapshot() { let activeTab = { id: null, @@ -5867,6 +5895,21 @@ async function collectMcpDiagnosticsSnapshot() { } } catch (_error) {} + let attachment = null; + let tabsSummary = { totalTabs: 0, activeTabId: activeTab.id }; + try { + attachment = await getMcpAttachmentMetadata(); + const [windows, tabs] = await Promise.all([ + chrome.windows.getAll({ windowTypes: ['normal'] }), + chrome.tabs.query({}) + ]); + const normalWindowIds = new Set(windows.map(window => window.id)); + tabsSummary = { + totalTabs: tabs.filter(tab => normalWindowIds.has(tab.windowId)).length, + activeTabId: activeTab.id + }; + } catch (_error) {} + let bridgeClient = null; try { if (chrome.storage?.session?.get) { @@ -5875,16 +5918,43 @@ async function collectMcpDiagnosticsSnapshot() { } } catch (_error) {} + if (attachment && bridgeClient) { + attachment.connectedAt = bridgeClient.lastConnectedAt || null; + } + return { success: true, activeTab, contentScript: getContentScriptDiagnosticsForTab(activeTab.id, activeTab.url), - bridgeClient + bridgeClient, + attachment, + tabsSummary }; } // Enhanced content script injection with retry logic and page load checks +const contentScriptInjectionFlights = new Map(); async function ensureContentScriptInjected(tabId, maxRetries = 3) { + let flight = contentScriptInjectionFlights.get(tabId); + if (!flight) { + flight = ensureContentScriptInjectedUnlocked(tabId, maxRetries); + contentScriptInjectionFlights.set(tabId, flight); + flight.finally(() => { + if (contentScriptInjectionFlights.get(tabId) === flight) contentScriptInjectionFlights.delete(tabId); + }).catch(() => {}); + } + return Promise.race([flight, + new Promise((_, reject) => setTimeout(() => { + // A flight whose Chrome callback never arrives must not pin the tab; + // the next caller starts a fresh injection. + if (contentScriptInjectionFlights.get(tabId) === flight) contentScriptInjectionFlights.delete(tabId); + const error = new Error('Content script injection timed out'); + error.code = 'PAGE_UNRESPONSIVE'; + reject(error); + }, 12000))]); +} + +async function ensureContentScriptInjectedUnlocked(tabId, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Wait for page to be fully loaded before health check @@ -5909,6 +5979,11 @@ async function ensureContentScriptInjected(tabId, maxRetries = 3) { // Check port connection first - most reliable indicator const portInfo = contentScriptPorts.get(tabId); + if (portInfo && Date.now() - portInfo.lastHeartbeat >= 10000) { + try { portInfo.port.disconnect(); } catch (_error) {} + contentScriptPorts.delete(tabId); + contentScriptReadyStatus.delete(tabId); + } if (portInfo && Date.now() - portInfo.lastHeartbeat < 10000) { automationLogger.logComm(null, 'health', 'port_healthy', true, { tabId, source: 'port' }); return true; @@ -10008,6 +10083,9 @@ async function sendMessageWithRetry(tabId, message, maxRetries = 3) { } for (let attempt = 1; attempt <= maxRetries; attempt++) { + // Per attempt: an earlier dispatch only reaches a retry after the + // no-receiving-end error, which proves it was not delivered. + let messageDispatched = false; try { // Check content script health before every attempt (not just the first) const isHealthy = await checkContentScriptHealth(tabId); @@ -10016,8 +10094,15 @@ async function sendMessageWithRetry(tabId, message, maxRetries = 3) { await ensureContentScriptInjected(tabId); } + if (message.action === 'executeAction' && Number.isFinite(message._fsbDeadlineAt) + && Date.now() >= message._fsbDeadlineAt) { + return { success: false, errorCode: 'PAGE_UNRESPONSIVE', outcome: 'failed', + mayHaveExecuted: false, error: 'The page did not respond before action delivery.' }; + } + // CRITICAL: Use frameId: 0 to target ONLY the main frame // This prevents responding from iframes (like Google's RotateCookiesPage iframe) + messageDispatched = true; const response = await chrome.tabs.sendMessage(tabId, message, { frameId: 0 }); // Success - reset health tracking @@ -10032,6 +10117,18 @@ async function sendMessageWithRetry(tabId, message, maxRetries = 3) { } catch (error) { const failureType = classifyFailure(error, message); automationLogger.logComm(null, 'send', message.action || 'unknown', false, { tabId, attempt, failureType, error: error.message }); + + // A closed port can mean the page acted and navigated before replying. + // Only the explicit "no receiving end" error proves non-delivery. + if (message.action === 'executeAction' && messageDispatched + && !/receiving end does not exist/i.test(error.message || '')) { + return { + success: false, + outcome: 'unknown', + mayHaveExecuted: true, + error: 'The action may have run before the content-script connection closed. Inspect the page before retrying.' + }; + } // Update health tracking const health = contentScriptHealth.get(tabId) || { failures: 0 }; @@ -10113,10 +10210,14 @@ async function sendMessageWithRetry(tabId, message, maxRetries = 3) { // Alternative action strategies for failed operations async function tryAlternativeAction(sessionId, originalAction, originalError) { + if (originalError?.mayHaveExecuted || originalError?.outcome === 'unknown') return null; const session = activeSessions.get(sessionId); if (!session) return null; const { tool, params } = originalAction; + if (['type', 'type_text', 'insert_text', 'press_key', 'press_enter'].includes(tool)) { + return null; + } const alternatives = []; // Type action alternatives @@ -10135,14 +10236,9 @@ async function tryAlternativeAction(sessionId, originalAction, originalError) { // Click action alternatives if (tool === 'click') { - alternatives.push( - // Try different click methods - { tool: 'doubleClick', params, description: `Try double-click instead` }, - { tool: 'rightClick', params, description: `Try right-click to trigger context` }, - // Try hovering first - { tool: 'hover', params, description: `Hover before clicking` }, - { tool: 'click', params: { ...params, forceClick: true }, description: `Force click ignoring visibility` } - ); + // A dispatched click may have mutated the page even without a visible + // response. The caller must inspect state before choosing another action. + return null; } // Selector alternatives for any action with selector @@ -19106,9 +19202,93 @@ async function handleCDPInsertText(request, sender, sendResponse) { return runLegacyCdpMessageWithLease(handleCDPInsertTextUnlocked, request, sender, sendResponse); } +async function prepareCdpTextTarget(tabId, selector, position) { + if (!selector && position === 'caret') return { success: true }; + const [result] = await chrome.scripting.executeScript({ + target: { tabId }, + func: (css, placement) => { + let element; + try { + if (css) { + const matches = Array.from(document.querySelectorAll(css)); + if (matches.length !== 1) return { success: false, error: 'Selector must identify exactly one editable field' }; + element = matches[0]; + } else { + element = document.activeElement; + } + } catch (error) { + return { success: false, error: `Invalid editable selector: ${error.message}` }; + } + // Canvas editors (Google Docs) keep focus in a nested text-event frame + // this script cannot reach. The key and insert events go to that focused + // frame, so its own editor handles the selection. + if (!css && element?.tagName === 'IFRAME') return { success: true }; + if (element?.isContentEditable) { + element = element.closest('[contenteditable="true"], [contenteditable=""]') || element; + } else if (element && !['INPUT', 'TEXTAREA'].includes(element.tagName)) { + const candidates = element.querySelectorAll('input:not([type="hidden"]), textarea, [contenteditable="true"], [contenteditable=""]'); + if (candidates.length !== 1) return { success: false, error: 'Target is not one editable field' }; + element = candidates[0]; + } + if (!element || (!['INPUT', 'TEXTAREA'].includes(element.tagName) && !element.isContentEditable)) { + return { success: false, error: 'Target is not editable' }; + } + element.focus(); + const previous = ['INPUT', 'TEXTAREA'].includes(element.tagName) ? element.value : element.innerText; + if (placement === 'end' || placement === 'replace_all') { + if (typeof element.setSelectionRange === 'function') { + const at = placement === 'end' ? element.value.length : 0; + element.setSelectionRange(at, placement === 'end' ? at : element.value.length); + } else { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + if (placement === 'end') range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + } + return { success: true, previous }; + }, + args: [selector || null, position] + }); + return result?.result || { success: false, error: 'Unable to inspect editable field' }; +} + +async function dispatchCdpTextInsertion(tabId, text, position = 'caret', selector = null) { + if (!['caret', 'end', 'replace_all'].includes(position)) { + return { success: false, error: 'Invalid insertion position' }; + } + const prepared = await prepareCdpTextTarget(tabId, selector, position); + if (!prepared.success) return prepared; + try { + if (position === 'replace_all') { + const isMac = typeof navigator !== 'undefined' && + (/Macintosh/.test(navigator.userAgent || '') || /Mac/.test(navigator.platform || '')); + const modifiers = isMac ? 4 : 2; + await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', { + type: 'keyDown', modifiers, key: 'a', code: 'KeyA', + windowsVirtualKeyCode: 65, nativeVirtualKeyCode: 65, + commands: ['selectAll'] + }); + await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', { + type: 'keyUp', modifiers, key: 'a', code: 'KeyA', + windowsVirtualKeyCode: 65, nativeVirtualKeyCode: 65 + }); + } + await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text }); + } catch (error) { + // Input may already have reached the page, so a partial edit is possible. + if (error && typeof error === 'object') error.mayHaveExecuted = true; + throw error; + } + return { success: true, text, length: text.length, position, mayHaveExecuted: true }; +} + async function handleCDPInsertTextUnlocked(request, sender, sendResponse) { const tabId = sender.tab?.id; - const { text, clearFirst } = request; + const { text, clearFirst, selector } = request; + const position = request.position || (clearFirst ? 'replace_all' : 'caret'); if (!tabId) { sendResponse({ success: false, error: 'No tab ID available' }); @@ -19121,6 +19301,7 @@ async function handleCDPInsertTextUnlocked(request, sender, sendResponse) { } let debuggerAttached = false; + let textInserted = false; try { automationLogger.logActionExecution(null, 'cdpInsertText', 'start', { tabId, textLength: text.length }); @@ -19128,67 +19309,14 @@ async function handleCDPInsertTextUnlocked(request, sender, sendResponse) { await attachFsbDebugger(tabId, 'cdpInsertText'); debuggerAttached = true; - // If clearFirst is requested, select all and delete - if (clearFirst) { - // Detect platform: modifier 4 = Meta (Cmd) on macOS, modifier 2 = Ctrl on others - const isMac = navigator.userAgent?.includes('Macintosh') || navigator.platform?.includes('Mac'); - const selectAllModifier = isMac ? 4 : 2; - - // Select all text in focused element - await chrome.debugger.sendCommand( - { tabId }, - 'Input.dispatchKeyEvent', - { - type: 'keyDown', - modifiers: selectAllModifier, - key: 'a', - code: 'KeyA' - } - ); - await chrome.debugger.sendCommand( - { tabId }, - 'Input.dispatchKeyEvent', - { - type: 'keyUp', - modifiers: selectAllModifier, - key: 'a', - code: 'KeyA' - } - ); - - // Delay for selection -- Monaco needs ~200ms to process Ctrl+A and update its internal model - await new Promise(r => setTimeout(r, 200)); - - // Delete selected text - await chrome.debugger.sendCommand( - { tabId }, - 'Input.dispatchKeyEvent', - { - type: 'keyDown', - key: 'Backspace', - code: 'Backspace' - } - ); - await chrome.debugger.sendCommand( - { tabId }, - 'Input.dispatchKeyEvent', - { - type: 'keyUp', - key: 'Backspace', - code: 'Backspace' - } - ); - - // Delay for deletion -- Monaco needs time to clear its buffer before accepting new input - await new Promise(r => setTimeout(r, 200)); + const inserted = await dispatchCdpTextInsertion(tabId, text, position, selector); + if (!inserted.success) { + await chrome.debugger.detach({ tabId }); + debuggerAttached = false; + sendResponse(inserted); + return; } - - // Use Input.insertText for reliable text insertion - await chrome.debugger.sendCommand( - { tabId }, - 'Input.insertText', - { text } - ); + textInserted = true; // Detach debugger await chrome.debugger.detach({ tabId }); @@ -19214,7 +19342,12 @@ async function handleCDPInsertTextUnlocked(request, sender, sendResponse) { } } - sendResponse(cdpFailureResult(error, { method: 'cdp' })); + // Attach and target failures happen before any input and keep their + // retryable classification. + const failure = cdpFailureResult(error, { method: 'cdp' }); + sendResponse(textInserted || error?.mayHaveExecuted + ? { ...failure, outcome: 'unknown', mayHaveExecuted: true, retryable: false } + : failure); } } @@ -19773,13 +19906,21 @@ async function executeCDPToolDirect(request, tabId) { return await leaseApi.run( tabId, () => executeCDPToolDirectUnlocked(request, tabId), - { timeoutMs: 10000 } + { timeoutMs: 10000, holdMs: cdpToolLeaseHoldMs(request.params) } ); } catch (error) { return cdpFailureResult(error); } } +// Hold and drag verbs run for as long as the caller asks; the lease watchdog +// has to outlast them. +function cdpToolLeaseHoldMs(params) { + const p = params || {}; + const perStepMs = (Number(p.stepDelayMs) || Number(p.maxDelayMs) || 0) + 50; + return 20000 + (Number(p.holdMs) || 0) + (Number(p.steps) || 0) * perStepMs; +} + async function executeCDPToolDirectUnlocked(request, tabId) { const { tool: verb, params } = request; @@ -20035,41 +20176,21 @@ async function executeCDPToolDirectUnlocked(request, tabId) { // cdpInsertText: Input.insertText with optional clearFirst // ----------------------------------------------------------------- case 'cdpInsertText': { - const { text, clearFirst } = params || {}; + const { text, clearFirst, selector } = params || {}; + const position = params?.position || (clearFirst ? 'replace_all' : 'caret'); if (!text) { return { success: false, error: 'cdpInsertText: no text provided' }; } let debuggerAttached = false; + let textInserted = false; try { automationLogger.logActionExecution(null, 'cdpInsertText', 'start', { tabId, textLength: text.length }); await attachDebugger(); debuggerAttached = true; - if (clearFirst) { - const isMac = (typeof navigator !== 'undefined' && navigator.userAgent?.includes('Macintosh')) || - (typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')); - const selectAllModifier = isMac ? 4 : 2; - - // Select all - await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', { - type: 'keyDown', modifiers: selectAllModifier, key: 'a', code: 'KeyA' - }); - await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', { - type: 'keyUp', modifiers: selectAllModifier, key: 'a', code: 'KeyA' - }); - await new Promise(r => setTimeout(r, 200)); - - // Delete selected - await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', { - type: 'keyDown', key: 'Backspace', code: 'Backspace' - }); - await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', { - type: 'keyUp', key: 'Backspace', code: 'Backspace' - }); - await new Promise(r => setTimeout(r, 200)); - } - - await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text }); + const inserted = await dispatchCdpTextInsertion(tabId, text, position, selector); + if (!inserted.success) return inserted; + textInserted = true; await chrome.debugger.detach({ tabId }); debuggerAttached = false; @@ -20078,7 +20199,10 @@ async function executeCDPToolDirectUnlocked(request, tabId) { return { success: true, method: 'cdp_direct', text, length: text.length }; } catch (error) { automationLogger.logActionExecution(null, 'cdpInsertText', 'complete', { success: false, tabId, error: error.message }); - return cdpFailureResult(error); + const failure = cdpFailureResult(error); + return textInserted || error?.mayHaveExecuted + ? { ...failure, outcome: 'unknown', mayHaveExecuted: true, retryable: false } + : failure; } finally { if (debuggerAttached) { try { await chrome.debugger.detach({ tabId }); } catch (_e) { /* ignore */ } @@ -20149,7 +20273,7 @@ async function executeCDPToolDirectUnlocked(request, tabId) { */ async function handleMonacoEditorInsert(request, sender, sendResponse) { const tabId = sender.tab?.id; - const { text } = request; + const { text, clearFirst = true } = request; if (!tabId || !text) { sendResponse({ success: false, error: !tabId ? 'No tab ID' : 'No text provided' }); @@ -20160,8 +20284,8 @@ async function handleMonacoEditorInsert(request, sender, sendResponse) { const results = await chrome.scripting.executeScript({ target: { tabId }, world: 'MAIN', - args: [text], - func: (codeText) => { + args: [text, clearFirst], + func: (codeText, replaceAll) => { // Attempt 1: Monaco editor API if (typeof monaco !== 'undefined' && monaco.editor) { const editors = typeof monaco.editor.getEditors === 'function' @@ -20172,8 +20296,14 @@ async function handleMonacoEditorInsert(request, sender, sendResponse) { const model = editor.getModel(); if (model) { const fullRange = model.getFullModelRange(); + const editRange = replaceAll ? fullRange : { + startLineNumber: fullRange.endLineNumber, + startColumn: fullRange.endColumn, + endLineNumber: fullRange.endLineNumber, + endColumn: fullRange.endColumn + }; editor.executeEdits('fsb-automation', [{ - range: fullRange, + range: editRange, text: codeText }]); // Move cursor to end @@ -20189,8 +20319,14 @@ async function handleMonacoEditorInsert(request, sender, sendResponse) { if (models.length > 0) { const model = models[0]; const fullRange = model.getFullModelRange(); + const editRange = replaceAll ? fullRange : { + startLineNumber: fullRange.endLineNumber, + startColumn: fullRange.endColumn, + endLineNumber: fullRange.endLineNumber, + endColumn: fullRange.endColumn + }; model.pushEditOperations([], [{ - range: fullRange, + range: editRange, text: codeText }], () => null); return { success: true, method: 'monaco_pushEditOperations' }; @@ -20202,7 +20338,7 @@ async function handleMonacoEditorInsert(request, sender, sendResponse) { if (cmElement?.cmView?.view) { const view = cmElement.cmView.view; view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: codeText } + changes: { from: replaceAll ? 0 : view.state.doc.length, to: view.state.doc.length, insert: codeText } }); return { success: true, method: 'codemirror6_dispatch' }; } @@ -20447,7 +20583,10 @@ async function handleKeyboardDebuggerAction(request, sender, sendResponse) { tabId = sender.tab.id; if (globalThis.FsbCdpLease && typeof globalThis.FsbCdpLease.acquire === 'function') { - cdpLease = await globalThis.FsbCdpLease.acquire(tabId, { timeoutMs: 10000 }); + cdpLease = await globalThis.FsbCdpLease.acquire(tabId, { + timeoutMs: 10000, + holdMs: keyboardActionLeaseHoldMs(method, text, keys, delay) + }); } automationLogger.logActionExecution(null, `keyboard_${method}`, 'start', { tabId, key, specialKey }); @@ -20523,6 +20662,17 @@ async function handleKeyboardDebuggerAction(request, sender, sendResponse) { } } +// Typing holds the debugger for every key. Budget each one generously (the +// inter-key delay, the key down/up gap, and two CDP round trips) so a long +// string is never handed to the next CDP caller part-way through. +function keyboardActionLeaseHoldMs(method, text, keys, delay) { + let presses = 1; + if (method === 'typeText' && typeof text === 'string') presses = text.length; + if (method === 'pressKeySequence' && Array.isArray(keys)) presses = keys.length; + const perPressMs = (Number.isFinite(delay) ? Math.max(0, delay) : 50) + 100; + return 20000 + presses * perPressMs; +} + /** * Clean up keyboard emulator resources when tab is closed */ diff --git a/extension/content/actions.js b/extension/content/actions.js index 391a97ddb..f0d56d068 100644 --- a/extension/content/actions.js +++ b/extension/content/actions.js @@ -10,6 +10,34 @@ const FSB = window.FSB; const logger = FSB.logger; + function normalizeEditorText(value) { + return String(value ?? '').replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' '); + } + + function resolveTextEntryTarget(element) { + if (!element) return null; + if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') return element; + if (element.isContentEditable) { + return element.closest('[contenteditable="true"], [contenteditable=""]') || element; + } + const descendants = element.querySelectorAll('input:not([type="hidden"]), textarea, [contenteditable="true"], [contenteditable=""]'); + return descendants.length === 1 ? descendants[0] : null; + } + + function readEditorText(element) { + return normalizeEditorText(element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' + ? element.value : element.innerText); + } + + function selectEditableInsertion(element, replace) { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + if (!replace) range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + // ============================================================================= // COORDINATE FALLBACK UTILITIES // Used when all selectors fail and stored coordinates are available @@ -149,29 +177,12 @@ async function clickAtCoordinates(params) { element.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); element.dispatchEvent(new MouseEvent('click', mouseEventInit)); - // Also call native click as fallback - if (typeof element.click === 'function') { - element.click(); - } - // Wait for potential effects await waitForStability('click'); - // Check if DOM click had effect; if not, try CDP mouse as final fallback - let clickMethod = 'dom_coordinate'; - try { - const cdpResult = await chrome.runtime.sendMessage({ - action: 'cdpMouseClick', - x: viewportCenterX, - y: viewportCenterY - }); - if (cdpResult?.success) { - clickMethod = 'cdp_coordinate'; - await waitForStability('click'); - } - } catch (e) { - // CDP unavailable, DOM click already dispatched - } + // The event may have triggered a network action without a visible DOM change. + // Never dispatch a second click on an uncertain outcome. + const clickMethod = 'dom_coordinate'; logger.log('info', 'Coordinate fallback click executed', { sessionId: FSB.sessionId, @@ -186,7 +197,9 @@ async function clickAtCoordinates(params) { }); return { - success: true, + success: false, + outcome: 'unknown', + mayHaveExecuted: true, fallbackUsed: true, clickedElement: { tag: element.tagName, @@ -196,7 +209,7 @@ async function clickAtCoordinates(params) { coordinates: { x: viewportCenterX, y: viewportCenterY }, scrolled: scrollResult.scrolled, method: clickMethod, - message: `Clicked using ${clickMethod} fallback (selector-based approach failed)` + message: 'Click dispatched at coordinates; inspect the page before retrying because its effect is unknown' }; } @@ -2333,6 +2346,10 @@ const tools = { const isObscured = readiness.failureReason && readiness.failureReason.includes('obscured'); if (isObscured && element && typeof element.click === 'function') { try { + if (selectorUsed && !selectorUsed.startsWith('[text-match:') && + FSB.querySelectorWithShadow(selectorUsed) !== element) { + return buildFailureReport('click', selectorUsed, null, 'Selector changed before click'); + } element.click(); await delay(300); return { @@ -2382,6 +2399,13 @@ const tools = { if (!document.contains(element)) { return buildFailureReport('click', params.selector, null, 'Element no longer in DOM'); } + if (selectorUsed && !selectorUsed.startsWith('[text-match:') && + FSB.querySelectorWithShadow(selectorUsed) !== element) { + return buildFailureReport('click', selectorUsed, null, 'Selector changed before click'); + } + if (params.text && !(element.innerText || element.textContent || '').toLowerCase().includes(params.text.toLowerCase())) { + return buildFailureReport('click', params.text, null, 'Text target changed before click'); + } // FIX: Handle target="_blank" links that would open in a new tab // Instead, navigate in the current tab for automation continuity @@ -2437,9 +2461,6 @@ const tools = { element.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); element.dispatchEvent(new MouseEvent('click', mouseEventInit)); - // Also call native click as fallback for some elements - element.click(); - // VERIFY-04: Wait for page stability (REPLACE fixed 300ms with dynamic stability detection) await waitForPageStability({ maxWait: 1000, stableTime: 200 }); @@ -2483,148 +2504,28 @@ const tools = { // CRITICAL FIX: Return success=false when click has no effect // This prevents AI from continuing after failed clicks if (!hadEffect) { - // FALLBACK: For anchor tags with valid href, try direct navigation - // Google and other sites may intercept click events, preventing programmatic navigation - const failedAnchor = element.tagName === 'A' ? element : element.closest('a'); - if (failedAnchor && failedAnchor.href && - failedAnchor.href.startsWith('http') && - !failedAnchor.href.includes('javascript:')) { - logger.logActionExecution(FSB.sessionId, 'click', 'href_fallback', { - href: failedAnchor.href, - originalSelector: params.selector - }); - window.location.href = failedAnchor.href; - return { - success: true, - clicked: params.selector, - hadEffect: true, - navigationTriggered: true, - method: 'href-fallback', - message: 'Click had no effect, navigated via href fallback', - targetUrl: failedAnchor.href, - elementInfo: { - tag: element.tagName, - text: element.textContent?.trim().substring(0, 50), - wasScrolledIntoView: wasScrolled - } - }; - } - - // FALLBACK 2: For form submit buttons, try form.submit() - const isSubmitButton = (element.tagName === 'INPUT' && element.type === 'submit') || - (element.tagName === 'BUTTON' && (element.type === 'submit' || !element.type)); - const parentForm = element.closest('form'); - if (isSubmitButton && parentForm) { - logger.logActionExecution(FSB.sessionId, 'click', 'form_submit_fallback', { - formAction: parentForm.action, - originalSelector: params.selector - }); - try { - parentForm.submit(); - return { - success: true, - clicked: params.selector, - hadEffect: true, - navigationTriggered: true, - method: 'form-submit-fallback', - message: 'Click had no effect, submitted form directly', - elementInfo: { - tag: element.tagName, - text: element.textContent?.trim().substring(0, 50) || element.value?.substring(0, 50), - wasScrolledIntoView: wasScrolled - } - }; - } catch (formError) { - logger.warn('Form submit fallback failed', { error: formError.message }); - } - } - - // FALLBACK 3: CDP mouse click at element coordinates (browser-level input) - // Bypasses React synthetic events, Shadow DOM, and event listener interception - try { - const cdpRect = element.getBoundingClientRect(); - const cdpX = cdpRect.left + cdpRect.width / 2; - const cdpY = cdpRect.top + cdpRect.height / 2; - - logger.logActionExecution(FSB.sessionId, 'click', 'cdp_mouse_fallback', { - x: Math.round(cdpX), y: Math.round(cdpY), selector: params.selector - }); - - const cdpResult = await chrome.runtime.sendMessage({ - action: 'cdpMouseClick', - x: cdpX, - y: cdpY - }); - - if (cdpResult?.success) { - await waitForPageStability({ maxWait: 1500, stableTime: 200 }); - const postState2 = captureActionState(element, 'click'); - const verification2 = verifyActionEffect(preState, postState2, 'click'); - const cdpHadEffect = verification2.verified || - verification2.changes?.urlChanged || - verification2.changes?.contentChanged || - verification2.changes?.elementCountChanged; - - if (cdpHadEffect) { - actionRecorder.record(null, 'click', params, { - selectorTried, selectorUsed: selectorTried, elementFound: true, - coordinatesUsed: { x: Math.round(cdpX), y: Math.round(cdpY) }, - coordinateSource: 'cdp_mouse', success: true, hadEffect: true, - duration: Date.now() - startTime - }); - return { - success: true, - clicked: params.selector, - hadEffect: true, - method: 'cdp-mouse-fallback', - message: 'DOM click had no effect, CDP mouse click succeeded', - elementInfo: { - tag: element.tagName, - text: element.textContent?.trim().substring(0, 50), - wasScrolledIntoView: wasScrolled - } - }; - } - } - } catch (cdpErr) { - logger.debug('CDP mouse fallback unavailable', { error: cdpErr.message, sessionId: FSB.sessionId }); - } - - // Record action - click had no effect (all fallbacks exhausted) const clickNoEffectDiagnostic = diagnoseElementFailure(selectorTried, element); actionRecorder.record(null, 'click', params, { selectorTried, - selectorUsed: selectorTried, + selectorUsed, elementFound: true, elementDetails: captureElementDetails(element), - coordinatesUsed: { x: Math.round(centerX), y: Math.round(centerY) }, - coordinateSource: 'selector', success: false, - error: 'Click executed but had no detectable effect on the page', + outcome: 'unknown', + mayHaveExecuted: true, hadEffect: false, - effectDetails: verification.changes, - verification: { - verified: verification.verified, - changes: verification.changes, - reason: verification.reason - }, diagnostic: clickNoEffectDiagnostic, duration: Date.now() - startTime }); - const clickNoEffectReport = buildFailureReport('click', selectorTried, element, 'Click executed but had no detectable effect on the page', clickNoEffectDiagnostic); - clickNoEffectReport.clicked = params.selector; - clickNoEffectReport.hadEffect = false; - clickNoEffectReport.verification = { - preState, - postState, - verified: verification.verified, - changes: verification.changes, - reason: verification.reason, - localChanges: verification.localChanges, - confidence: verification.confidence, - whatChanged: verification.whatChanged + return { + success: false, + outcome: 'unknown', + mayHaveExecuted: true, + clicked: params.selector, + hadEffect: false, + error: 'Click was dispatched but its effect could not be confirmed. Inspect the page before retrying.', + verification }; - return clickNoEffectReport; } // Record successful action @@ -2832,6 +2733,7 @@ const tools = { // Type text into an input type: async (params) => { const startTime = Date.now(); + const clearFirst = params.clear_first !== false && params.clearFirst !== false; logger.logActionExecution(FSB.sessionId, 'type', 'start', params); // Build selectors array for alternative selector support @@ -2844,6 +2746,7 @@ const tools = { // Try each selector until one succeeds with verified effect for (let selectorIndex = 0; selectorIndex < selectors.length; selectorIndex++) { const currentSelector = selectors[selectorIndex]; + let mutationAttempted = false; logger.debug('Trying selector for type', { sessionId: FSB.sessionId, selectorIndex, selector: currentSelector }); try { @@ -2870,22 +2773,27 @@ const tools = { } } + if (!FSB.isCanvasBasedEditor()) { + const editingTarget = resolveTextEntryTarget(element); + if (!editingTarget) { + lastAttemptError = 'Selector does not identify a single editable field'; + continue; + } + element = editingTarget; + } + logger.logActionExecution(FSB.sessionId, 'type', 'element_ready', { tagName: element.tagName, scrolled: readiness.scrolled }); // Capture pre-state for verification const preState = captureActionState(element, 'type'); + mutationAttempted = true; if (element) { // Check if it's a valid input element with enhanced contenteditable detection const isInput = element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'; // Enhanced universal text input detection for all platforms - const isContentEditable = element.contentEditable === 'true' || - element.getAttribute('contenteditable') === 'true' || - element.hasAttribute('contenteditable') || - element.getAttribute('role') === 'textbox' || - // Universal messaging patterns - FSB.isUniversalMessageInput(element); + const isContentEditable = element.isContentEditable === true; const codeEditorInfo = FSB.detectCodeEditor(element); const isCodeEditorInput = isInput && codeEditorInfo.isCodeEditor; @@ -2928,8 +2836,11 @@ const tools = { note: 'Google Sheets Name Box guard -- data redirected to active cell via keyboard emulator' }; } + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Keyboard insertion was not confirmed. Inspect the cell before retrying.' }; } catch (e) { - logger.debug('Name Box guard typeWithKeys failed, falling through', { error: e.message }); + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Keyboard insertion may have executed. Inspect the cell before retrying.' }; } } } @@ -2961,9 +2872,11 @@ const tools = { }; } } catch (twkError) { - logger.debug('Google Sheets typeWithKeys failed, falling through to CDP', { error: twkError.message }); + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Keyboard insertion may have executed. Inspect the cell before retrying.' }; } - // Fall through to standard CDP path as last resort + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Keyboard insertion was not confirmed. Inspect the cell before retrying.' }; } logger.logActionExecution(FSB.sessionId, 'type', 'canvas_editor_cdp_direct', { hostname: window.location.hostname }); @@ -2991,7 +2904,7 @@ const tools = { } // If clearFirst, select all and delete before pasting - if (params.clearFirst) { + if (clearFirst) { const isMac = navigator.userAgent?.includes('Macintosh') || navigator.platform?.includes('Mac'); await new Promise((resolve, reject) => { chrome.runtime.sendMessage({ @@ -3042,14 +2955,20 @@ const tools = { note: 'Google Docs -- markdown converted to HTML, pasted via clipboard for rich formatting' }; } - // If clipboard paste failed (verified -- no text appeared), fall through to plain CDP insertText - logger.warn('Formatted paste failed (verified), falling back to plain CDP insertText', { + logger.warn('Formatted paste was not confirmed', { error: pasteResult.error, textLenBefore: pasteResult.textLenBefore, - textLenAfter: pasteResult.textLenAfter + textLenAfter: pasteResult.textLenAfter, + nothingInserted: !!pasteResult.nothingInserted }); + if (!pasteResult.nothingInserted) { + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Formatted paste may have executed. Inspect the document before retrying.' }; + } + // The paste provably changed nothing; the plain insertion below is the only write. } catch (fmtError) { - logger.debug('Formatted paste error, falling back to plain CDP insertText', { error: fmtError.message }); + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Formatted paste may have executed. Inspect the document before retrying.' }; } } // --- END FORMATTED PASTE PATH --- @@ -3061,7 +2980,7 @@ const tools = { chrome.runtime.sendMessage({ action: 'cdpInsertText', text: cdpText, - clearFirst: !!params.clearFirst + clearFirst }, (response) => { if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); else if (response && response.success) resolve(response); @@ -3083,14 +3002,8 @@ const tools = { note: 'Canvas-based editor -- CDP insertion used, DOM validation skipped' }; } catch (cdpError) { - logger.debug('Canvas editor CDP failed, trying typeWithKeys', { error: cdpError.message }); - try { - const twkResult = await tools.typeWithKeys({ text: params.text, clearFirst: false }); - if (twkResult.success) return { ...twkResult, note: 'canvas_editor_typeWithKeys_fallback' }; - } catch (twkError) { - logger.debug('Canvas editor typeWithKeys also failed', { error: twkError.message }); - } - return { success: false, error: 'Canvas-based editor: CDP and typeWithKeys both failed', typed: params.text }; + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'CDP insertion may have executed. Inspect the editor before retrying.', typed: params.text }; } } @@ -3106,19 +3019,6 @@ const tools = { if (!shouldSkipClick) { element.click(); await waitForStability('light'); - - if (document.activeElement !== element && element.id) { - const label = document.querySelector(`label[for="${element.id}"]`); - if (label) { - label.click(); - await waitForStability('type_keystroke'); - } - } - - if (document.activeElement !== element && element.parentElement) { - element.parentElement.click(); - await waitForStability('type_keystroke'); - } } // Always focus after clicking @@ -3126,12 +3026,10 @@ const tools = { await waitForStability('type_keystroke'); // Final verification - ensure element is truly focused and ready - let focusAttempts = 0; - while (document.activeElement !== element && focusAttempts < 3) { - element.click(); - element.focus(); - await waitForStability('light'); - focusAttempts++; + const focusAttempts = 0; + if (document.activeElement !== element) { + return { success: false, outcome: 'failed', mayHaveExecuted: false, + error: 'Editable field did not receive focus' }; } // Universal text insertion handling for both input elements and contenteditable @@ -3152,7 +3050,8 @@ const tools = { const editorResult = await new Promise((resolve, reject) => { chrome.runtime.sendMessage({ action: 'monacoEditorInsert', - text: params.text + text: params.text, + clearFirst }, (response) => { if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); else if (response?.success) resolve(response); @@ -3183,10 +3082,16 @@ const tools = { } }; } catch (editorApiError) { - logger.debug('Editor API failed, falling through to CDP', { + if (/No editor API found on page/.test(editorApiError.message)) { + logger.debug('No editor API available; trying CDP', { sessionId: FSB.sessionId }); + } else { + logger.debug('Editor API result uncertain; skipping fallback', { sessionId: FSB.sessionId, error: editorApiError.message }); + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Editor insertion may have executed. Inspect the editor before retrying.' }; + } } } @@ -3203,7 +3108,7 @@ const tools = { chrome.runtime.sendMessage({ action: 'cdpInsertText', text: params.text, - clearFirst: true + clearFirst }, (response) => { if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); else if (response?.success) resolve(response); @@ -3233,10 +3138,12 @@ const tools = { } }; } catch (cdpCodeEditorError) { - logger.debug('CDP code editor fast-path failed, falling through to standard methods', { + logger.debug('CDP code editor result uncertain; skipping fallback', { sessionId: FSB.sessionId, error: cdpCodeEditorError.message }); + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'CDP insertion may have executed. Inspect the editor before retrying.' }; } } @@ -3249,7 +3156,8 @@ const tools = { if (!codeInserted && document.execCommand) { try { - element.select(); + if (clearFirst) element.select(); + else element.setSelectionRange(element.value.length, element.value.length); if (document.execCommand('insertText', false, params.text)) { codeInserted = true; } @@ -3259,44 +3167,11 @@ const tools = { } if (!codeInserted) { - try { - element.select(); - element.dispatchEvent(new InputEvent('beforeinput', { - inputType: 'insertText', - data: params.text, - bubbles: true, - cancelable: true, - composed: true - })); - element.dispatchEvent(new InputEvent('input', { - inputType: 'insertText', - data: params.text, - bubbles: true - })); - codeInserted = true; - } catch (e) { - logger.debug('Code editor InputEvent failed', { error: e.message }); - } - } - - if (!codeInserted) { - try { - const dataTransfer = new DataTransfer(); - dataTransfer.setData('text/plain', params.text); - element.dispatchEvent(new ClipboardEvent('paste', { - clipboardData: dataTransfer, - bubbles: true, - cancelable: true - })); - await waitForStability('type_keystroke'); - codeInserted = true; - } catch (e) { - logger.debug('Code editor clipboard paste failed', { error: e.message }); + if (element.value !== previousValue) { + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Editor content changed unexpectedly. Inspect it before retrying.' }; } - } - - if (!codeInserted) { - element.value = params.text; + element.value = clearFirst ? params.text : previousValue + params.text; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); } @@ -3310,96 +3185,41 @@ const tools = { } else if (isInput) { previousValue = element.value; - element.value = ''; - element.value = params.text; + element.value = clearFirst ? params.text : previousValue + params.text; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); } else if (isContentEditable) { - previousValue = element.textContent || element.innerText || ''; - insertionSuccess = false; - - if (!insertionSuccess && document.execCommand) { - try { - element.focus(); - document.execCommand('selectAll', false, null); - if (document.execCommand('insertText', false, params.text)) { - insertionSuccess = true; - } - } catch (e) { - logger.debug('execCommand insertText failed', { sessionId: FSB.sessionId, error: e.message }); - } - } - - if (!insertionSuccess) { - try { - const dataTransfer = new DataTransfer(); - dataTransfer.setData('text/plain', params.text); - const pasteEvent = new ClipboardEvent('paste', { - clipboardData: dataTransfer, - bubbles: true, - cancelable: true - }); - element.dispatchEvent(pasteEvent); - await waitForStability('type_keystroke'); - if (element.textContent.includes(params.text)) { - insertionSuccess = true; - } - } catch (e) { - logger.debug('Clipboard paste simulation failed', { sessionId: FSB.sessionId, error: e.message }); - } - } - - if (!insertionSuccess) { - try { - element.innerHTML = ''; - element.textContent = ''; - const textNode = document.createTextNode(params.text); - element.appendChild(textNode); - const range = document.createRange(); - const selection = window.getSelection(); - range.setStartAfter(textNode); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - insertionSuccess = true; - } catch (e) { - logger.debug('Range/Selection API insertion failed', { sessionId: FSB.sessionId, error: e.message }); - } + previousValue = readEditorText(element); + element.focus(); + selectEditableInsertion(element, clearFirst); + let commandReportedSuccess = false; + try { + commandReportedSuccess = Boolean(document.execCommand && + document.execCommand('insertText', false, params.text)); + } catch (error) { + logger.debug('Editable insertText failed', { sessionId: FSB.sessionId, error: error.message }); } - + await waitForStability('type_keystroke'); + const expected = clearFirst + ? normalizeEditorText(params.text) + : previousValue + normalizeEditorText(params.text); + const observed = readEditorText(element); + insertionSuccess = observed === expected; if (!insertionSuccess) { - if (element.innerHTML.includes('


') || element.innerHTML.includes('
')) { - element.innerHTML = ''; - } else { - element.textContent = ''; - } - try { - element.textContent = params.text; - insertionSuccess = true; - } catch (e) { - logger.debug('Direct manipulation failed', { sessionId: FSB.sessionId, error: e.message }); - } + const mayHaveExecuted = commandReportedSuccess || observed !== previousValue; + return { + success: false, + outcome: mayHaveExecuted ? 'unknown' : 'failed', + mayHaveExecuted, + error: mayHaveExecuted + ? 'Editable text changed but did not match the requested text. Inspect it before retrying.' + : 'The editable field did not accept the text.', + expectedValue: expected, + actualValue: observed, + final_text: observed + }; } - - const events = [ - new Event('input', { bubbles: true }), - new Event('change', { bubbles: true }), - new KeyboardEvent('keydown', { bubbles: true }), - new KeyboardEvent('keyup', { bubbles: true }), - new Event('blur', { bubbles: true }), - new Event('focus', { bubbles: true }) - ]; - - events.forEach(event => { - try { - element.dispatchEvent(event); - } catch (e) { - logger.debug('Event dispatch failed', { sessionId: FSB.sessionId, eventType: event.type, error: e.message }); - } - }); - - await waitForStability('type_keystroke'); } // Gmail/email recipient field: dispatch Tab to confirm the recipient "chip" @@ -3438,52 +3258,23 @@ const tools = { element.dispatchEvent(enterUpEvent); } - // Post-typing validation - const finalValue = isInput ? (element.value || '') : (element.textContent || element.value || ''); - const typingSuccessful = finalValue.includes(params.text) || finalValue === params.text; - - // Amazon-specific validation + // Compare the rendered editor value, preserving line breaks and detecting + // duplicate inserts before any further action can mutate the field. + const finalCheck = readEditorText(element); + const expectedValue = clearFirst + ? normalizeEditorText(params.text) + : normalizeEditorText(previousValue) + normalizeEditorText(params.text); + const finalSuccess = finalCheck === expectedValue; const isAmazonSearch = element.id === 'twotabsearchtextbox' || - element.name === 'searchtext' || - window.location.hostname.includes('amazon'); - - if (isAmazonSearch && !typingSuccessful) { - logger.logActionExecution(FSB.sessionId, 'type', 'amazon_retry', { reason: 'initial_typing_failed' }); - try { - element.focus(); - await waitForStability('light'); - element.value = ''; - element.value = params.text; - element.dispatchEvent(new Event('input', { bubbles: true })); - element.dispatchEvent(new Event('change', { bubbles: true })); - element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: params.text.slice(-1) })); - element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: params.text.slice(-1) })); - await waitForStability('type_complete'); - const retryValue = element.value || ''; - if (retryValue.includes(params.text) || retryValue === params.text) { - logger.logActionExecution(FSB.sessionId, 'type', 'amazon_retry_success', {}); - } - } catch (amazonError) { - logger.warn('Amazon-specific retry failed', { sessionId: FSB.sessionId, error: amazonError.message }); - } - } - - // CRITICAL FIX: Strengthen validation - const finalCheck = isInput ? (element.value || '') : (element.textContent || element.value || ''); - const trimmedFinal = finalCheck.trim(); - const trimmedExpected = params.text.trim(); - const exactMatch = trimmedFinal === trimmedExpected; - const contentEditableMatch = isContentEditable && - trimmedFinal.replace(/\s+/g, ' ') === trimmedExpected.replace(/\s+/g, ' '); - const finalSuccess = exactMatch || contentEditableMatch; - const contentEditableActuallyWorked = !isContentEditable || (insertionSuccess && finalSuccess); + element.name === 'searchtext' || + window.location.hostname.includes('amazon'); // Gmail recipient chip check if (isRecipientField && looksLikeEmail && !finalSuccess) { const chipEl = element.closest('[role="list"], .fX, .afV')?.querySelector( '.vR, [data-hovercard-id], [data-name], .afX' ); - const fieldCleared = trimmedFinal === '' || !trimmedFinal.includes(params.text); + const fieldCleared = finalCheck.trim() === '' || !finalCheck.includes(params.text); if (chipEl || fieldCleared) { logger.debug('Recipient chip detected or field cleared after Tab, treating as success', { sessionId: FSB.sessionId, chipFound: !!chipEl, fieldCleared @@ -3505,96 +3296,33 @@ const tools = { } } - // Return failure if typing didn't work - if (!finalSuccess || (isContentEditable && !isCodeEditorInput && !insertionSuccess)) { - const recheck = isInput ? (element.value || '') : (element.textContent || element.innerText || ''); - if (recheck.includes(params.text)) { - return { - success: true, - typed: params.text, - method: 'standard', - pressedEnter: !!params.pressEnter, - clickedFirst: !shouldSkipClick, - hadEffect: true, - note: 'recheck_confirmed_text_present', - elementInfo: { - tag: element.tagName, - type: isInput ? element.type : 'contenteditable', - name: element.name || element.id || element.className - } - }; - } - - // ENHANCED: Try CDP-based text insertion as last resort - logger.logActionExecution(FSB.sessionId, 'type', 'cdp_fallback_attempt', { reason: 'standard_methods_failed' }); - - try { - const cdpResult = await new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ - action: 'cdpInsertText', - text: params.text, - clearFirst: true - }, (response) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - } else if (response && response.success) { - resolve(response); - } else { - reject(new Error(response?.error || 'CDP insertion failed')); - } - }); - }); - - await waitForStability('type_complete'); - const cdpCanvasEditor = FSB.isCanvasBasedEditor(); - const cdpFinalCheck = cdpCanvasEditor ? '' : (isInput ? (element.value || '') : (element.textContent || element.value || '')); - const cdpSuccess = cdpCanvasEditor || cdpFinalCheck.includes(params.text) || cdpFinalCheck.trim() === params.text.trim(); - - if (cdpSuccess) { - logger.logActionExecution(FSB.sessionId, 'type', cdpCanvasEditor ? 'cdp_fallback_canvas_success' : 'cdp_fallback_success', {}); - return { - success: true, - typed: params.text, - method: cdpCanvasEditor ? 'cdp_fallback_canvas' : 'cdp_fallback', - pressedEnter: !!params.pressEnter, - clickedFirst: !shouldSkipClick, - hadEffect: true, - note: cdpCanvasEditor ? 'Canvas-based editor -- DOM validation skipped, CDP trusted' : undefined, - elementInfo: { - tag: element.tagName, - type: isInput ? element.type : 'contenteditable', - name: element.name || element.id || element.className - } - }; - } - } catch (cdpError) { - logger.debug('CDP fallback failed', { sessionId: FSB.sessionId, error: cdpError.message }); - } - + if (!finalSuccess || (isContentEditable && !insertionSuccess)) { + const mayHaveExecuted = finalCheck !== normalizeEditorText(previousValue); return { success: false, - error: isContentEditable - ? 'ContentEditable insertion failed - text not entered correctly (CDP fallback also failed)' - : 'Text validation failed - expected text not found in element', + outcome: mayHaveExecuted ? 'unknown' : 'failed', + mayHaveExecuted, + error: mayHaveExecuted + ? 'Text changed but did not match the requested value. Inspect the field before retrying.' + : 'The field did not accept the requested text.', typed: params.text, actualValue: finalCheck, - expectedValue: params.text, - pressedEnter: !!params.pressEnter, - clickedFirst: !shouldSkipClick, - focused: document.activeElement === element, - insertionSuccess: isContentEditable ? insertionSuccess : undefined, - validationPassed: false, - cdpAttempted: true, - elementInfo: { - tag: element.tagName, - type: isInput ? element.type : 'contenteditable', - previousValue: previousValue.substring(0, 20), - name: element.name || element.id || FSB.getClassName(element), - contentEditable: isContentEditable - }, - suggestion: isContentEditable - ? 'Try alternative selector or wait for page to be ready' - : 'Element may not accept input correctly' + expectedValue, + final_text: finalCheck + }; + } + + if (isContentEditable) { + return { + success: true, + typed: params.text, + final_text: finalCheck, + actualValue: finalCheck, + selector: currentSelector, + hadEffect: true, + insertionSuccess: true, + validationPassed: true, + pressedEnter: !!params.pressEnter }; } @@ -3607,13 +3335,8 @@ const tools = { lastVerification = verification; if (!verification.verified) { - logger.debug('Type verification failed, trying next selector', { - sessionId: FSB.sessionId, - selector: currentSelector, - reason: verification.reason - }); - lastAttemptError = `Type action had no verified effect: ${verification.reason}`; - continue; // Try next selector + verification.verified = true; + verification.reason = 'Rendered editor value matches the requested text'; } // Record successful action @@ -3644,6 +3367,7 @@ const tools = { changes: verification.changes }, actualValue: finalCheck, + final_text: finalCheck, pressedEnter: !!params.pressEnter, clickedFirst: !shouldSkipClick, focused: document.activeElement === element, @@ -3702,6 +3426,11 @@ const tools = { currentSelector: currentSelector }); + if (mutationAttempted) { + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Text insertion may have executed. Inspect the field before retrying.' }; + } + if (lastVerification && lastVerification.verified) { logger.warn('Type verification passed but post-success error occurred, returning success', { sessionId: FSB.sessionId, @@ -3754,10 +3483,13 @@ const tools = { note: 'Google Sheets fallback -- keyboard emulator used (all selectors exhausted)' }; } + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Keyboard insertion was not confirmed. Inspect the cell before retrying.' }; } catch (twkErr) { logger.debug('Google Sheets fallback typeWithKeys failed', { error: twkErr.message }); + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Keyboard insertion may have executed. Inspect the cell before retrying.' }; } - // Fall through to standard canvas CDP fallback as last resort } logger.logActionExecution(FSB.sessionId, 'type', 'canvas_fallback_attempt', { @@ -3784,7 +3516,7 @@ const tools = { chrome.runtime.sendMessage({ action: 'cdpInsertText', text: params.text, - clearFirst: !!params.clearFirst + clearFirst }, (response) => { if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); else if (response && response.success) resolve(response); @@ -3805,13 +3537,8 @@ const tools = { }; } catch (cdpFallbackErr) { logger.debug('Canvas editor CDP fallback failed', { error: cdpFallbackErr.message }); - try { - const twkResult = await tools.typeWithKeys({ text: params.text, clearFirst: false }); - if (twkResult.success) return { ...twkResult, note: 'canvas_editor_fallback_typeWithKeys' }; - } catch (twkErr) { - logger.debug('Canvas editor fallback typeWithKeys also failed', { error: twkErr.message }); - } - lastAttemptError = `Canvas editor CDP fallback failed: ${cdpFallbackErr.message}`; + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'CDP insertion may have executed. Inspect the editor before retrying.' }; } } @@ -3870,7 +3597,6 @@ const tools = { } const isInsideForm = !!element.closest('form'); - const formElement = element.closest('form'); const preState = captureActionState(element, 'pressEnter'); element.focus(); @@ -3894,51 +3620,16 @@ const tools = { lastVerification = verification; if (!verification.verified && isInsideForm) { - // Phase 129: Enter had no effect -- try clicking the submit button as fallback - const submitButton = findSubmitButton(formElement); - if (submitButton) { - const fallbackPreState = captureActionState(submitButton, 'click'); - submitButton.click(); - await waitForPageStability({ maxWait: 2000, stableTime: 300 }); - const fallbackPostState = captureActionState(submitButton, 'click'); - const fallbackVerification = verifyActionEffect(fallbackPreState, fallbackPostState, 'click'); - - if (fallbackVerification.verified) { - actionRecorder.record(null, 'pressEnter', params, { - selectorTried: params.selector, - selectorUsed: currentSelector, - elementFound: true, - elementDetails: captureElementDetails(submitButton), - coordinatesUsed: null, - coordinateSource: null, - success: true, - hadEffect: true, - usedSubmitFallback: true, - effectDetails: fallbackVerification.changes, - duration: Date.now() - startTime - }); - - return { - success: true, - key: 'Enter', - selector: currentSelector, - selectorIndex: selectorIndex, - usedFallback: selectorIndex > 0, - usedSubmitFallback: true, - submitButtonSelector: submitButton.id ? `#${submitButton.id}` : submitButton.className ? `.${submitButton.className.split(' ')[0]}` : submitButton.tagName.toLowerCase(), - hadEffect: true, - isInsideForm: true, - verification: { - verified: true, - reason: 'Submit button click fallback triggered form submission', - changes: fallbackVerification.changes - } - }; - } - } - // Submit button not found or click had no effect either -- continue to next selector - lastAttemptError = `Enter key pressed but form submission had no effect${submitButton ? ' (submit button fallback also failed)' : ' (no submit button found in form)'}`; - continue; + return { + success: false, + outcome: 'unknown', + mayHaveExecuted: true, + key: 'Enter', + selector: currentSelector, + hadEffect: false, + error: 'Enter was dispatched but submission could not be confirmed. Inspect the page before retrying.', + verification + }; } actionRecorder.record(null, 'pressEnter', params, { @@ -4245,6 +3936,7 @@ const tools = { const { element: searchInput, tier, selector: matchedSelector } = detected; + let submissionAttempted = false; try { // Ensure element is ready (also dismisses cookie consent per Phase 130) if (typeof FSB.smartEnsureReady === 'function') { @@ -4278,27 +3970,17 @@ const tools = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }); + submissionAttempted = true; searchInput.dispatchEvent(enterDown); searchInput.dispatchEvent(enterUp); // Wait for page stability after submit await waitForPageStability({ maxWait: 3000, stableTime: 500 }); - // If URL did not change, try submit button fallback (reuse Phase 129 findSubmitButton) if (window.location.href === preUrl) { - const form = searchInput.closest('form'); - if (form) { - const submitBtn = findSubmitButton(form) || - form.querySelector('[role="button"][aria-label*="search" i]'); - if (submitBtn) { - submitBtn.click(); - await waitForPageStability({ maxWait: 3000, stableTime: 500 }); - } else { - // Last resort: submit the form directly - form.submit(); - await waitForPageStability({ maxWait: 3000, stableTime: 500 }); - } - } + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Search submission may have executed. Inspect the page before retrying.', + query: params.query, searchInputSelector: matchedSelector, tier }; } return { @@ -4310,7 +3992,11 @@ const tools = { url: window.location.href }; } catch (error) { - // On error, fall back to Google + if (submissionAttempted) { + return { success: false, outcome: 'unknown', mayHaveExecuted: true, + error: 'Search submission may have executed. Inspect the page before retrying.', + query: params.query, searchInputSelector: matchedSelector, tier }; + } const googleResult = tools.searchGoogle(params); return { ...googleResult, method: 'google-fallback', siteSearchError: error.message }; } @@ -4683,12 +4369,26 @@ const tools = { if (response.success) { return { success: true, key, method: 'debuggerAPI', target: selector || 'activeElement', result: response.result }; } else { + if (response.result?.keyDownDispatched || response.result?.mayHaveExecuted) { + return { + success: false, + outcome: 'unknown', + mayHaveExecuted: true, + key, + error: 'The key may have reached the page. Inspect its effect before retrying.' + }; + } cdpError = (response && response.error) || 'debugger API returned failure'; logger.logRecovery(FSB.sessionId, 'debugger_api_failed', 'dom_events_fallback', 'started', { error: response.error }); } } catch (error) { - cdpError = error.message || 'debugger API unavailable'; - logger.logRecovery(FSB.sessionId, 'debugger_api_unavailable', 'dom_events_fallback', 'started', { error: error.message }); + return { + success: false, + outcome: 'unknown', + mayHaveExecuted: true, + key, + error: 'The key dispatch lost its response. Inspect the page before retrying.' + }; } } diff --git a/extension/content/dom-state.js b/extension/content/dom-state.js index de13027d5..8f37c6537 100644 --- a/extension/content/dom-state.js +++ b/extension/content/dom-state.js @@ -529,6 +529,18 @@ return null; } + if (!selector.includes('>>>') && !selector.startsWith('/')) { + try { + if (typeof element.matches !== 'function' || !element.matches(selector)) { + this.cache.delete(selector); + return null; + } + } catch (_error) { + this.cache.delete(selector); + return null; + } + } + return element; } diff --git a/extension/content/messaging.js b/extension/content/messaging.js index c8c98e520..80e4efafd 100644 --- a/extension/content/messaging.js +++ b/extension/content/messaging.js @@ -460,8 +460,11 @@ /** * Write HTML to the system clipboard and simulate paste via CDP. + * nothingInserted is true only when the paste provably left the document + * unchanged, so the caller can fall back to a plain insert without duplicating text. */ async function clipboardPasteHTML(html, plainText) { + let pasteDispatched = false; try { const htmlBlob = new Blob([html], { type: 'text/html' }); const textBlob = new Blob([plainText], { type: 'text/plain' }); @@ -474,24 +477,28 @@ await navigator.clipboard.write([clipboardItem]); } catch (clipErr) { logger.warn('clipboardPasteHTML: clipboard.write() failed', { error: clipErr.message }); - return { success: false, method: 'clipboard_paste_html', error: 'Clipboard write failed: ' + clipErr.message }; + return { success: false, method: 'clipboard_paste_html', error: 'Clipboard write failed: ' + clipErr.message, + nothingInserted: true }; } logger.debug('clipboardPasteHTML: clipboard written', { htmlLength: html.length, plainTextLength: plainText.length }); await new Promise(r => setTimeout(r, 150)); - const getDocTextLength = () => { + // Canvas-rendered Docs have no paragraph elements, so an unchanged length there proves nothing. + const measureDocText = () => { const pageElements = document.querySelectorAll('.kix-paragraphrenderer'); - let totalLen = 0; + let length = 0; for (const el of pageElements) { - totalLen += (el.textContent || '').length; + length += (el.textContent || '').length; } - return totalLen; + return { length, measurable: pageElements.length > 0 }; }; - const textLenBefore = getDocTextLength(); + const before = measureDocText(); + const textLenBefore = before.length; const isMac = navigator.userAgent?.includes('Macintosh') || navigator.platform?.includes('Mac'); + pasteDispatched = true; const pasteResult = await new Promise((resolve, reject) => { chrome.runtime.sendMessage({ action: 'keyboardDebuggerAction', @@ -514,13 +521,20 @@ await new Promise(r => setTimeout(r, 800)); - const textLenAfter = getDocTextLength(); + // A large paste can land late; recheck before treating it as absent. + let after = measureDocText(); + for (let recheck = 0; recheck < 3 && after.length <= textLenBefore; recheck++) { + await new Promise(r => setTimeout(r, 400)); + after = measureDocText(); + } + const textLenAfter = after.length; const textInserted = textLenAfter > textLenBefore; logger.debug('clipboardPasteHTML: verification', { textLenBefore, textLenAfter, textInserted, + measurable: before.measurable && after.measurable, expectedMinChars: Math.min(plainText.length, 10) }); @@ -531,14 +545,15 @@ method: 'clipboard_paste_html', error: 'Paste dispatched but no text appeared in editor (cursor may not be in editable area)', textLenBefore, - textLenAfter + textLenAfter, + nothingInserted: before.measurable && after.measurable && textLenAfter === textLenBefore }; } return { success: true, method: 'clipboard_paste_html', textLenBefore, textLenAfter }; } catch (error) { logger.warn('clipboardPasteHTML failed', { error: error.message }); - return { success: false, method: 'clipboard_paste_html', error: error.message }; + return { success: false, method: 'clipboard_paste_html', error: error.message, nothingInserted: !pasteDispatched }; } } diff --git a/extension/content/selectors.js b/extension/content/selectors.js index f6bcd2fcb..324372563 100644 --- a/extension/content/selectors.js +++ b/extension/content/selectors.js @@ -448,44 +448,7 @@ * @returns {string} A valid CSS selector */ function sanitizeSelector(selector) { - if (!selector || typeof selector !== 'string') { - return selector; - } - - // Remove jQuery-style pseudo-selectors that aren't valid CSS - const invalidPseudos = [ - /:contains\([^)]*\)/gi, // :contains('text') - jQuery only - /:has\([^)]*\)/gi, // :has() - limited browser support, can cause issues - /:eq\(\d+\)/gi, // :eq(n) - jQuery only - /:gt\(\d+\)/gi, // :gt(n) - jQuery only - /:lt\(\d+\)/gi, // :lt(n) - jQuery only - /:first(?![-\w])/gi, // :first - jQuery only (but not :first-child, :first-of-type) - /:last(?![-\w])/gi, // :last - jQuery only (but not :last-child, :last-of-type) - /:even/gi, // :even - jQuery only - /:odd/gi, // :odd - jQuery only - /:visible/gi, // :visible - jQuery only - /:hidden/gi, // :hidden - jQuery only - /:animated/gi, // :animated - jQuery only - /:parent/gi // :parent - jQuery only - ]; - - let sanitized = selector; - for (const pattern of invalidPseudos) { - sanitized = sanitized.replace(pattern, ''); - } - - // Clean up any resulting empty selectors or dangling commas - // Split by comma, filter out empty parts, rejoin - const parts = sanitized.split(',') - .map(s => s.trim()) - .filter(s => s.length > 0 && s !== '*'); // Remove empty or just asterisk - - if (parts.length === 0) { - logger.warn('Selector completely invalidated after sanitization', { sessionId: FSB.sessionId, selector }); - return null; - } - - return parts.join(', '); + return typeof selector === 'string' ? selector.trim() || null : null; } // ============================================================================ @@ -495,7 +458,7 @@ // Query selector that supports shadow DOM // SPEED-04: Uses elementCache for faster repeated lookups function querySelectorWithShadow(selector) { - // Sanitize selector first to remove invalid pseudo-selectors + // Never strip selector conditions: unsupported syntax must fail closed. const sanitized = sanitizeSelector(selector); if (!sanitized) { logger.warn('Cannot query with invalid selector', { sessionId: FSB.sessionId, selector }); diff --git a/extension/site-guides/index.js b/extension/site-guides/index.js index 51b517acd..6c921aac4 100644 --- a/extension/site-guides/index.js +++ b/extension/site-guides/index.js @@ -238,6 +238,8 @@ function getGuideForTask(task, url) { } } } + if (bestMatch?.site === 'Twitter/X' && url && + !bestMatch.patterns.some(pattern => pattern.test(url))) return null; if (bestMatch) return bestMatch; return null; diff --git a/extension/site-guides/social/twitter.js b/extension/site-guides/social/twitter.js index fa45924ec..5a4ac7060 100644 --- a/extension/site-guides/social/twitter.js +++ b/extension/site-guides/social/twitter.js @@ -15,7 +15,7 @@ registerSiteGuide({ site: 'Twitter/X', category: 'Social Media', patterns: [ - /(twitter\.com|x\.com)/i + /^https?:\/\/(?:www\.|mobile\.)?(?:twitter\.com|x\.com)(?::\d+)?(?:[/?#]|$)/i ], guidance: `AUTOPILOT STRATEGY HINTS (from v0.9.7 diagnostic SCROLL-01): - [scroll] Track tweets by permalink href Set -- virtualized DOM recycles ~20-40 elements @@ -45,7 +45,10 @@ SEARCH: COMPOSING POSTS: - Post text area: [data-testid="tweetTextarea_0"] - Post/Tweet button (inline): [data-testid="tweetButtonInline"] +- Post/Tweet button (modal composer): [data-testid="tweetButton"] - New post button (sidebar): [data-testid="SideNav_NewTweet_Button"] or [aria-label="Post"] +- Before posting, inspect the account switcher and confirm the intended account is active. +- Before replying, open the intended post and verify its author and permalink in the reply context. - Schedule post: [data-testid="scheduleOption"] or [aria-label="Schedule post"] - Grok AI enhancement: [data-testid="grokImgGen"] or [aria-label="Enhance your post with Grok"] - Add GIF: [data-testid="gifSearchButton"] @@ -137,8 +140,9 @@ SCROLL TIMING: searchBox: 'input[data-testid="SearchBox_Search_Input"]', tweetCompose: '[data-testid="tweetTextarea_0"]', tweetButton: '[data-testid="tweetButtonInline"]', + modalTweetButton: '[data-testid="tweetButton"]', replyInput: '[data-testid="tweetTextarea_0"]', - replyButton: '[data-testid="tweetButton"]', + replyButton: '[data-testid="tweetButtonInline"]', likeButton: '[data-testid="like"]', retweetButton: '[data-testid="retweet"]', dmButton: '[data-testid="sendDMFromProfile"]', @@ -173,12 +177,20 @@ SCROLL TIMING: }, workflows: { createPost: [ + 'Check [data-testid="SideNav_AccountSwitcher_Button"] and confirm the intended account', 'Click the compose area or new post button', 'Wait for editor to load', 'Type the post content in [data-testid="tweetTextarea_0"]', - 'Click the Post button [data-testid="tweetButtonInline"]', + 'Use [data-testid="tweetButtonInline"] for inline compose or [data-testid="tweetButton"] for the modal composer', 'Verify post was created' ], + replyToPost: [ + 'Open the intended post and verify its author and /status/ permalink', + 'Confirm the active account in [data-testid="SideNav_AccountSwitcher_Button"]', + 'Type in the reply editor [data-testid="tweetTextarea_0"]', + 'Click the Post button inside the same reply composer only once', + 'Verify the reply appears under the intended post before trying again' + ], sendMessage: [ 'Click Messages tab [data-testid="AppTabBar_DirectMessage_Link"]', 'Open new message or find existing conversation', @@ -209,6 +221,8 @@ SCROLL TIMING: ] }, warnings: [ + 'Check the active account before posting, replying, liking, or sending a message', + 'Check the author and permalink of the parent post before replying', 'Twitter/X rate-limits actions -- avoid rapid clicking', 'X/Twitter now has Grok AI integration for post enhancement via [data-testid="grokImgGen"]', 'X/Twitter navigation uses AppTabBar data-testid pattern -- more reliable than aria-label which can include notification counts', diff --git a/extension/ui/onboarding.css b/extension/ui/onboarding.css index c0256e8eb..18b020f49 100644 --- a/extension/ui/onboarding.css +++ b/extension/ui/onboarding.css @@ -271,9 +271,9 @@ a:hover { } .ob-node.active { - background: rgba(255, 107, 53, 0.12); + background: var(--primary); border-color: var(--primary); - color: var(--primary); + color: #111827; box-shadow: 0 0 0 4px rgba(255, 107, 53, 0.1); } diff --git a/extension/utils/cdp-lease.js b/extension/utils/cdp-lease.js index 01f9e914d..4a0194a40 100644 --- a/extension/utils/cdp-lease.js +++ b/extension/utils/cdp-lease.js @@ -9,6 +9,7 @@ 'use strict'; const queues = new Map(); + const DEFAULT_HOLD_MS = 20000; function busyError(tabId) { const error = new Error(`The debugger for tab ${tabId} is busy. Retry the operation.`); @@ -18,19 +19,28 @@ return error; } - function makeLease(tabId, state) { + function holdMsFrom(options) { + return Number.isFinite(options.holdMs) && options.holdMs > 0 + ? options.holdMs + : DEFAULT_HOLD_MS; + } + + // The watchdog frees the tab from a holder that hung. Holders whose work + // legitimately runs longer pass holdMs so they are not preempted mid-use. + function makeLease(tabId, state, holdMs) { let released = false; - return { + const lease = { tabId, release() { if (released) return; released = true; + clearTimeout(watchdog); while (state.waiters.length > 0) { const waiter = state.waiters.shift(); if (waiter.cancelled) continue; clearTimeout(waiter.timer); - waiter.resolve(makeLease(tabId, state)); + waiter.resolve(makeLease(tabId, state, waiter.holdMs)); return; } @@ -38,6 +48,9 @@ queues.delete(tabId); } }; + const watchdog = setTimeout(() => lease.release(), holdMs); + if (typeof watchdog.unref === 'function') watchdog.unref(); + return lease; } function acquire(tabId, options = {}) { @@ -48,6 +61,7 @@ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, options.timeoutMs) : 10000; + const holdMs = holdMsFrom(options); let state = queues.get(tabId); if (!state) { state = { active: false, waiters: [] }; @@ -56,11 +70,11 @@ if (!state.active) { state.active = true; - return Promise.resolve(makeLease(tabId, state)); + return Promise.resolve(makeLease(tabId, state, holdMs)); } return new Promise((resolve, reject) => { - const waiter = { resolve, reject, cancelled: false, timer: null }; + const waiter = { resolve, reject, holdMs, cancelled: false, timer: null }; waiter.timer = setTimeout(() => { waiter.cancelled = true; reject(busyError(tabId)); diff --git a/extension/utils/keyboard-emulator.js b/extension/utils/keyboard-emulator.js index 0074ebff0..512b1d161 100644 --- a/extension/utils/keyboard-emulator.js +++ b/extension/utils/keyboard-emulator.js @@ -343,6 +343,7 @@ class KeyboardEmulator { * @returns {Promise} Result object */ async sendKeyEvent(tabId, type, key, modifiers = {}) { + let commandSent = false; try { const attached = await this.attachDebugger(tabId); if (!attached) { @@ -399,6 +400,7 @@ class KeyboardEmulator { } } + commandSent = true; await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', params); return { @@ -416,6 +418,7 @@ class KeyboardEmulator { error: error.message || 'Key event dispatch failed', code: error && error.code, retryable: Boolean(error && error.retryable), + mayHaveExecuted: commandSent, key, type }; @@ -434,7 +437,7 @@ class KeyboardEmulator { // Send keyDown event const downResult = await this.sendKeyEvent(tabId, 'keyDown', key, modifiers); if (!downResult.success) { - return downResult; + return { ...downResult, keyDownDispatched: Boolean(downResult.mayHaveExecuted) }; } // Small delay between down and up @@ -443,7 +446,7 @@ class KeyboardEmulator { // Send keyUp event const upResult = await this.sendKeyEvent(tabId, 'keyUp', key, modifiers); if (!upResult.success) { - return upResult; + return { ...upResult, keyDownDispatched: true, mayHaveExecuted: true }; } return { diff --git a/extension/utils/network-capture.js b/extension/utils/network-capture.js index 6dbdb6be6..8c76c1729 100644 --- a/extension/utils/network-capture.js +++ b/extension/utils/network-capture.js @@ -298,12 +298,13 @@ } // Hold the same per-tab FIFO lease used by screenshots and Input/DOM CDP - // operations for the entire time-boxed discovery session. + // operations for the entire time-boxed discovery session, plus time for + // the Network.disable/detach that runs before endSession releases it. var cdpLease = null; var leaseApi = _cdpLease(); if (leaseApi && typeof leaseApi.acquire === 'function') { try { - cdpLease = await leaseApi.acquire(tabId, { timeoutMs: 10000 }); + cdpLease = await leaseApi.acquire(tabId, { timeoutMs: 10000, holdMs: maxMs + 10000 }); } catch (_leaseErr) { return { ok: false, reason: 'RECIPE_CAPTURE_BUSY' }; } diff --git a/extension/ws/mcp-bridge-client.js b/extension/ws/mcp-bridge-client.js index 6e5302c6c..def0126ba 100644 --- a/extension/ws/mcp-bridge-client.js +++ b/extension/ws/mcp-bridge-client.js @@ -35,6 +35,24 @@ const MCP_RECONNECT_ALARM = 'fsb-mcp-bridge-reconnect'; const MCP_RECONNECT_BASE_MS = 2000; const MCP_RECONNECT_MAX_MS = 30000; const MCP_PING_INTERVAL_MS = 25000; +// The ordinary keepalive is the only liveness detector a non-delegation socket +// has. The daemon answers every mcp:ping inline before any routing, so a miss +// means the peer is gone rather than busy; four consecutive unanswered ticks +// (~100s) is the point at which a silent socket is treated as dead. That sits +// just above the daemon's own 90s staleness window so the two reapers do not +// race to claim the same socket. +const MCP_PING_MISS_LIMIT = 3; +// A peer that has stopped answering will not finish a closing handshake +// either, and Chrome waits 60s for one before it fires onclose. Past this grace +// the close path runs without it, as the pairing reload already does. +const MCP_KEEPALIVE_CLOSE_GRACE_MS = 1000; +// A 1008 close is the daemon deciding, not the transport failing, and the +// upgrade that preceded it already reset the backoff. Refusals are counted +// separately and the delay derives from the count, so the reset cannot flatten +// it back into an unbounded flat-rate retry. +const MCP_AUTH_BACKOFF_STEP_LIMIT = 8; +const MCP_CLOSE_REASON_MAX_LENGTH = 64; +const MCP_CLOSE_REASON_AUTH_REVOKED = 'Extension authorization revoked'; const DELEGATION_HEARTBEAT_INTERVAL_MS = 20000; const DELEGATION_HEARTBEAT_MISS_LIMIT = 3; const DELEGATION_HEARTBEAT_NONCE_MIN_LENGTH = 16; @@ -127,6 +145,12 @@ class MCPBridgeClient { this._lastDisconnectReason = null; this._nextReconnectAt = null; this._reconnectAttemptCount = 0; + // Consecutive daemon refusals (close code 1008). Deliberately not reset by + // onopen: the upgrade succeeding is exactly what makes a refusal invisible + // to the ordinary delay. Only a completed round trip clears it. + this._authRefusalStreak = 0; + this._lastPongAt = null; + this._unansweredPings = 0; // Phase 241 D-08 -- per-bridge-connect connection_id state. // _connectionId is minted at every _ws.onopen via crypto.randomUUID() and // threaded through agent:register so the registry can stamp it on each @@ -167,6 +191,8 @@ class MCPBridgeClient { lastConnectedAt: this._lastConnectedAt, lastDisconnectedAt: this._lastDisconnectedAt, lastDisconnectReason: this._lastDisconnectReason, + authRefusalStreak: this._authRefusalStreak, + lastPongAt: this._lastPongAt, delegationConnection: this.getDelegationConnectionSnapshot(), updatedAt: this._timestamp() }; @@ -197,6 +223,9 @@ class MCPBridgeClient { this._lastConnectAttemptAt = this._timestamp(); this._persistState(); + // Captured here because the credential this socket presents may be gone by + // the time it closes -- see the 1008 handling in onclose. + const openedWithCredential = !!this._pairingCode; try { this._ws = this._pairingCode ? new WebSocket(MCP_BRIDGE_URL, [FSB_EXT_PROTOCOL, this._pairingCode]) @@ -224,6 +253,7 @@ class MCPBridgeClient { this._reconnectDelay = MCP_RECONNECT_BASE_MS; this._nextReconnectAt = null; this._lastConnectedAt = this._timestamp(); + this._sendExtensionState().catch(() => {}); this._lastDisconnectReason = null; this._clearReconnectAlarm(); this._delegationHeartbeatNonce = null; @@ -290,7 +320,11 @@ class MCPBridgeClient { this._handleMessage(event.data); }; - socket.onclose = () => { + socket.onclose = (event) => { + // A browser delivers a CloseEvent here, but several synthetic close paths + // invoke this with no argument at all, so every read is optional. + const closeCode = event && typeof event.code === 'number' ? event.code : null; + const closeReason = this._sanitizeCloseReason(event && event.reason); const wasReplacement = this._replacementSockets.delete(socket); if (this._ws !== socket) { this._rejectExtPendingForSocket(socket); @@ -304,7 +338,7 @@ class MCPBridgeClient { this._lastDisconnectedAt = this._timestamp(); this._lastDisconnectReason = this._intentionalClose ? 'intentional_close' - : (this._lastDisconnectReason === 'socket_error' ? 'socket_error' : 'socket_close'); + : this._describeSocketClose(closeCode, closeReason, this._lastDisconnectReason); // Phase 241 D-08 -- stage release for ALL agents stamped with the // current connection_id. The registry resolves the agentIds snapshot // at stage time (Q2 resolution) so a fresh agent claimed under a @@ -323,6 +357,35 @@ class MCPBridgeClient { // Phase 239 plan 03 -- arm reconciler for the next connect cycle. this._inFlightTasksReconciled = false; this._rejectAllExtPending(); + // A 1008 close is the daemon refusing this socket, not the transport + // failing. The upgrade succeeded milliseconds earlier, so onopen has + // already reset _reconnectDelay to the base; honouring that reset is what + // turns a refusal into an unbounded flat-rate retry. The refusal streak + // owns the delay instead, and only a completed round trip clears it. + // + // The exception is a revocation of a credential this socket presented: + // that is a verdict on the credential, the next handshake goes out + // without it, so it is not a repeat refusal. The daemon sends + // ext_unauthorized before this close and that frame usually drops the + // credential first, so the decision rests on what the socket opened + // with, not on what is still stored. A revocation of a socket that + // presented nothing is an ordinary refusal and backs off like one. + if (!this._intentionalClose && !wasReplacement && closeCode === 1008) { + const revokedCredential = closeReason === MCP_CLOSE_REASON_AUTH_REVOKED + && openedWithCredential; + if (revokedCredential) { + this._forgetPairingCredential('authorization_revoked'); + } else { + this._authRefusalStreak = Math.min( + this._authRefusalStreak + 1, + MCP_AUTH_BACKOFF_STEP_LIMIT, + ); + this._reconnectDelay = Math.min( + Math.round(MCP_RECONNECT_BASE_MS * Math.pow(1.5, this._authRefusalStreak)), + MCP_RECONNECT_MAX_MS, + ); + } + } if (this._pairingStatus === 'paired') { this._setPairingStatus(this._pairingCode ? 'configured' : 'unpaired'); } @@ -477,6 +540,45 @@ class MCPBridgeClient { return this._pairingLoadPromise; } + /** + * Drop a credential the daemon has told us is dead, in memory and in + * chrome.storage.session. The stored record has no expiry and no other + * remover, and it is scoped to the browser session rather than to the + * service worker, so without this it outlives every worker restart and every + * reconnect until the browser itself exits. + * + * Going unpaired is a downgrade, not an outage: authorization gates only the + * reverse channel, so every ordinary browser tool keeps working, and a + * delegation run mints a fresh code on its next preflight. + * + * Returns true when a credential was actually dropped. + */ + _forgetPairingCredential(reason) { + // A pairing reload has already written and loaded a fresh credential; a + // late failure from the socket being retired must not delete it. + if (this._pairingReloadPromise) return false; + if (!this._pairingCode) return false; + + this._pairingCode = null; + this._pairingLoaded = true; + this._pairingLoadPromise = null; + if (this._pairingStatus === 'configured' || this._pairingStatus === 'paired') { + this._setPairingStatus('expired'); + } + + try { + const area = typeof chrome !== 'undefined' ? chrome.storage?.session : null; + if (area && typeof area.remove === 'function') { + const result = area.remove(MCP_BRIDGE_PAIRING_KEY); + if (result && typeof result.catch === 'function') result.catch(() => {}); + } + } catch (_error) { /* best-effort; the in-memory clear is authoritative */ } + + console.log('[FSB MCP Bridge] Dropped a rejected pairing credential:', reason); + this._persistState(); + return true; + } + _notifySocketWaiters(event, socket) { for (const waiter of [...this._socketWaiters]) { try { waiter(event, socket); } catch (_error) { /* isolated waiter */ } @@ -552,6 +654,9 @@ class MCPBridgeClient { this._pairingLoaded = false; this._pairingLoadPromise = null; await this._ensurePairingLoaded(); + // A fresh credential is a materially different handshake, so re-pairing + // should not inherit a backoff the old one earned. + this._authRefusalStreak = 0; if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); @@ -674,7 +779,14 @@ class MCPBridgeClient { pending.resolve(response.payload); return; } - if (response.error.code === 'ext_unauthorized') this._setPairingStatus('expired'); + if (response.error.code === 'ext_unauthorized') { + // This error has exactly one producer: the daemon evaluated our + // credential against its current session and rejected it. Unlike a slot + // refusal, that is a verdict on the credential itself, so keeping it + // would just replay the same rejection on every reconnect forever. + this._setPairingStatus('expired'); + this._forgetPairingCredential('ext_unauthorized'); + } pending.reject(this._makeExtError( response.error.message, response.error.code, @@ -829,6 +941,23 @@ class MCPBridgeClient { }, delay); } + _sanitizeCloseReason(value) { + if (value === undefined || value === null) return ''; + const text = String(value).slice(0, MCP_CLOSE_REASON_MAX_LENGTH); + // The daemon's close reasons are a closed set of four literals, but the + // reason is peer-controlled text that lands in persisted diagnostics, so + // anything credential-shaped is dropped rather than echoed. + if (text.includes('fsb-auth.')) return ''; + return text.replace(/[^A-Za-z0-9 ._:-]/g, ''); + } + + _describeSocketClose(code, reason, priorReason) { + if (priorReason === 'keepalive_timeout') return 'keepalive_timeout'; + const base = priorReason === 'socket_error' ? 'socket_error' : 'socket_close'; + if (code === null) return base; + return reason ? `${base}_${code}:${reason}` : `${base}_${code}`; + } + _timestamp(time = Date.now()) { return new Date(time).toISOString(); } @@ -873,7 +1002,15 @@ class MCPBridgeClient { if (!alarms || typeof alarms.create !== 'function') return; try { - const result = alarms.create(MCP_RECONNECT_ALARM, { delayInMinutes: 0.5 }); + // delayInMinutes sits at the alarms floor rather than tracking the + // in-memory delay, which never exceeds it. periodInMinutes is what makes + // this a backstop: onopen clears this alarm on every cycle, so a one-shot + // wake is lost the moment an upgrade succeeds and is then refused -- + // exactly the state that needs a wake. + const result = alarms.create(MCP_RECONNECT_ALARM, { + delayInMinutes: 0.5, + periodInMinutes: 1, + }); if (result && typeof result.catch === 'function') { result.catch(() => {}); } @@ -889,10 +1026,31 @@ class MCPBridgeClient { _startPing() { this._stopPing(); if (this._delegationHeartbeatOwners.size > 0) return; + this._lastPongAt = null; + this._unansweredPings = 0; this._pingTimer = setInterval(() => { - if (this._ws && this._ws.readyState === WebSocket.OPEN) { - this._ws.send(JSON.stringify({ type: 'mcp:ping', ts: Date.now() })); + const socket = this._ws; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + if (this._unansweredPings >= MCP_PING_MISS_LIMIT) { + // The peer has ignored MCP_PING_MISS_LIMIT consecutive pings. A socket + // that still reads OPEN but answers nothing is what makes connect() + // no-op forever at its readyState guard, so hand the existing + // onclose -> _scheduleReconnect path a real close rather than adding + // another timer to watch this one. + this._lastDisconnectReason = 'keepalive_timeout'; + try { socket.close(); } catch (_error) { /* the grace timer below still runs onclose */ } + setTimeout(() => { + // onclose clears _ws, so still owning the socket means it has not run. + // When the real close arrives later it finds a stale socket and only + // settles that socket's leftovers. + if (this._ws !== socket || typeof socket.onclose !== 'function') return; + socket.onclose(); + }, MCP_KEEPALIVE_CLOSE_GRACE_MS); + return; } + this._unansweredPings += 1; + socket.send(JSON.stringify({ type: 'mcp:ping', ts: Date.now() })); + this._sendExtensionState().catch(() => {}); }, MCP_PING_INTERVAL_MS); } @@ -1052,6 +1210,27 @@ class MCPBridgeClient { this._persistState(); } + /** + * Liveness bookkeeping for any daemon pong, in both the nonce-free two-key + * shape the ordinary keepalive gets back and the three-key acknowledged + * shape. This is liveness only -- acknowledging a specific beat stays in + * _handleDelegationHeartbeatPong and still requires the exact nonce. + */ + _notePongFrame(msg) { + if (!this._isPlainRecord(msg) || msg.type !== 'mcp:pong') return; + if (!Number.isSafeInteger(msg.ts) || msg.ts < 0) return; + const keys = Object.keys(msg).sort(); + const plain = keys.length === 2 && keys[0] === 'ts' && keys[1] === 'type'; + const acknowledged = keys.length === 3 + && keys[0] === 'nonce' && keys[1] === 'ts' && keys[2] === 'type'; + if (!plain && !acknowledged) return; + this._lastPongAt = Date.now(); + this._unansweredPings = 0; + // A completed round trip is the only evidence that this socket was + // accepted rather than refused microseconds after the upgrade. + this._authRefusalStreak = 0; + } + // -------------------------------------------------------------------------- // Message handling // -------------------------------------------------------------------------- @@ -1129,6 +1308,12 @@ class MCPBridgeClient { } } + async _sendExtensionState() { + if (typeof getMcpAttachmentMetadata !== 'function') return; + const attachment = await getMcpAttachmentMetadata(); + this._send({ type: 'mcp:extension-state', ...attachment, connectedAt: this._lastConnectedAt || null }); + } + _sendResult(id, payload) { this._send({ id, type: 'mcp:result', payload }); } @@ -1157,6 +1342,7 @@ class MCPBridgeClient { } if (msg.type === 'mcp:pong') { + this._notePongFrame(msg); this._handleDelegationHeartbeatPong(msg); return; } @@ -1404,23 +1590,60 @@ class MCPBridgeClient { * MV3 content script lifecycle properly. */ async _sendToContentScript(tabId, message) { - // sendMessageWithRetry is defined in background.js (same scope) - if (typeof sendMessageWithRetry === 'function') { - return await sendMessageWithRetry(tabId, message); - } - // Fallback: inject then send directly - if (typeof ensureContentScriptInjected === 'function') { - await ensureContentScriptInjected(tabId); - } - return new Promise((resolve, reject) => { - chrome.tabs.sendMessage(tabId, message, { frameId: 0 }, (response) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - return; - } - resolve(response || {}); + const deliveryDeadline = Date.now() + 12000; + if (message.action === 'executeAction') message._fsbDeadlineAt = deliveryDeadline; + const uncertainResult = () => ({ success: false, outcome: 'unknown', mayHaveExecuted: true, + errorCode: 'PAGE_UNRESPONSIVE', error: 'The page did not answer. Inspect its state before retrying.' }); + // operation() rejects only when the message was never delivered; a + // possibly delivered action resolves with an uncertain result instead. + const operation = async () => { + // sendMessageWithRetry is defined in background.js (same scope). + if (typeof sendMessageWithRetry === 'function') { + return await sendMessageWithRetry(tabId, message); + } + if (typeof ensureContentScriptInjected === 'function') { + await ensureContentScriptInjected(tabId); + } + if (Date.now() >= deliveryDeadline) { + return { success: false, errorCode: 'PAGE_UNRESPONSIVE', + error: 'The page did not answer before the delivery deadline.' }; + } + return new Promise((resolve, reject) => { + chrome.tabs.sendMessage(tabId, message, { frameId: 0 }, (response) => { + if (chrome.runtime.lastError) { + const reason = chrome.runtime.lastError.message || ''; + if (message.action === 'executeAction' && !/receiving end does not exist/i.test(reason)) { + resolve(uncertainResult()); + return; + } + reject(new Error(reason)); + return; + } + resolve(response || {}); + }); }); - }); + }; + let timer; + try { + return await Promise.race([ + operation(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(message.action === 'executeAction' + ? uncertainResult() + : { success: false, errorCode: 'PAGE_UNRESPONSIVE', + error: 'The page did not answer within 12 seconds.' }), 12000); + }) + ]); + } catch (error) { + const reason = error?.message || String(error); + return message.action === 'executeAction' + ? { success: false, outcome: 'failed', mayHaveExecuted: false, + errorCode: 'PAGE_UNRESPONSIVE', error: `The action was not delivered: ${reason}` } + : { success: false, errorCode: 'PAGE_UNRESPONSIVE', + error: `The page did not answer: ${reason}` }; + } finally { + clearTimeout(timer); + } } async _handleGetTabs(payload = {}) { @@ -1552,11 +1775,11 @@ class MCPBridgeClient { if (!Number.isFinite(tabId) || tabId <= 0) return; if (typeof agentId !== 'string' || !agentId) return; try { - await MCPVisualSessionLifecycleUtils.recordVisualSessionTick(tabId, agentId, { + await Promise.race([MCPVisualSessionLifecycleUtils.recordVisualSessionTick(tabId, agentId, { visualReason: typeof sidecar.visualReason === 'string' ? sidecar.visualReason : '', client: typeof sidecar.client === 'string' ? sidecar.client : '', isFinal: sidecar.isFinal === true - }); + }), new Promise((resolve) => setTimeout(resolve, 750))]); } catch (err) { // Non-blocking: lifecycle failures must not break the underlying action. // The overlay simply does not light up. The action still executes per @@ -1599,7 +1822,8 @@ class MCPBridgeClient { if (!Number.isFinite(tabId) || tabId <= 0) return; if (typeof agentId !== 'string' || !agentId) return; try { - await MCPVisualSessionLifecycleUtils.clearVisualSession(tabId, { reason: 'is_final' }); + await Promise.race([MCPVisualSessionLifecycleUtils.clearVisualSession(tabId, { reason: 'is_final' }), + new Promise((resolve) => setTimeout(resolve, 750))]); } catch (err) { // Non-blocking: lifecycle failures must not break the underlying action. console.warn('[FSB MCP] clearVisualSession (is_final) failed (non-blocking):', err && err.message); @@ -1696,8 +1920,8 @@ class MCPBridgeClient { const replayContext = targetContext ? { routeFamily: (() => { try { - const definition = typeof getToolByName === 'function' - ? getToolByName(payload && payload.tool) + const definition = typeof getToolByNameOrVerb === 'function' + ? getToolByNameOrVerb(payload && payload.tool) : null; return definition && definition._route ? definition._route : 'content'; } catch (_e) { @@ -1753,7 +1977,17 @@ class MCPBridgeClient { const agentId = (payload && payload.agentId) || null; const params = payload && payload.params ? payload.params : {}; const toolName = payload && payload.tool; - const toolDef = typeof getToolByName === 'function' ? getToolByName(toolName) : null; + const toolDef = typeof getToolByNameOrVerb === 'function' ? getToolByNameOrVerb(toolName) : null; + if (!toolDef) { + return { + success: false, + errorCode: 'mcp_route_unavailable', + tool: toolName, + routeFamily: 'manual', + error: `Unknown MCP tool or wire verb: ${toolName}`, + recoveryHint: 'Update the FSB extension and MCP server, then retry with a supported tool.' + }; + } const usesDispatcherSyntheticChangeReport = MCP_DISPATCHER_SYNTHETIC_CHANGE_REPORT_TOOLS.has(toolName); const buildRouteParams = (extra) => ({ @@ -2859,6 +3093,16 @@ class MCPBridgeClient { // -------------------------------------------------------------------------- async _handleListCredentials() { + const status = await this._dispatchToBackground({ action: 'getCredentialVaultStatus' }); + if (!status || status.success === false) { + return { success: false, errorCode: 'vault_status_unavailable', error: status?.error || 'Credential vault status unavailable' }; + } + if (!status.configured) { + return { success: false, errorCode: 'vault_not_configured', error: 'Credential vault is not configured' }; + } + if (!status.unlocked) { + return { success: false, errorCode: 'vault_locked', error: 'Credential vault is locked' }; + } const response = await this._dispatchToBackground({ action: 'getAllCredentials' }); if (!response || !response.success) { return { success: false, error: response?.error || 'Failed to list credentials' }; @@ -2920,6 +3164,16 @@ class MCPBridgeClient { } async _handleListPayments() { + const status = await this._dispatchToBackground({ action: 'getPaymentVaultStatus' }); + if (!status || status.success === false) { + return { success: false, errorCode: 'vault_status_unavailable', error: status?.error || 'Payment vault status unavailable' }; + } + if (!status.configured) { + return { success: false, errorCode: 'vault_not_configured', error: 'Payment vault is not configured' }; + } + if (!status.unlocked || !status.paymentUnlocked) { + return { success: false, errorCode: 'vault_locked', error: 'Payment vault is locked' }; + } const response = await this._dispatchToBackground({ action: 'getAllPaymentMethods' }); if (!response || !response.success) { return { success: false, error: response?.error || 'Failed to list payment methods' }; diff --git a/extension/ws/mcp-tool-dispatcher.js b/extension/ws/mcp-tool-dispatcher.js index 66af215f6..d6553512f 100644 --- a/extension/ws/mcp-tool-dispatcher.js +++ b/extension/ws/mcp-tool-dispatcher.js @@ -1,11 +1,12 @@ 'use strict'; -// In Chrome extension importScripts context, TOOL_REGISTRY and getToolByName +// In Chrome extension importScripts context, registry helpers // are globals from ai/tool-definitions.js. In Node.js/tests, fall back to require(). var _mcp_defs = (typeof TOOL_REGISTRY !== 'undefined') - ? { TOOL_REGISTRY, getToolByName } + ? { TOOL_REGISTRY, getToolByName, getToolByNameOrVerb } : (typeof require !== 'undefined' ? require('../ai/tool-definitions.js') : {}); var _mcp_getToolByName = _mcp_defs.getToolByName; +var _mcp_getToolByNameOrVerb = _mcp_defs.getToolByNameOrVerb; // Phase 245 D-07: global toggle for change_report emission. Hydrated from // chrome.storage.local.fsbChangeReportsEnabled at module load and refreshed @@ -1187,7 +1188,7 @@ function sanitizeSingleTab(tool, tab, extra = {}) { } function shouldEmitSyntheticChangeReport(tool) { - const toolDef = (typeof _mcp_getToolByName === 'function') ? _mcp_getToolByName(tool) : null; + const toolDef = (typeof _mcp_getToolByNameOrVerb === 'function') ? _mcp_getToolByNameOrVerb(tool) : null; return !!(fsbChangeReportsEnabled && toolDef && toolDef._emitChangeReport === true); } @@ -3530,12 +3531,15 @@ async function handleFailTaskRoute({ params, payload }) { // `change_report_hint`) attached, or the unmodified response on any error. const _CHANGE_REPORT_SAFETY_NET_MS = 500; +const _PAGE_INJECTION_TIMEOUT_MS = 750; +let _changeReportSeq = 0; // Page-context harvest start: captures beforeState and starts a scoped -// MutationObserver. Stores the handle on window.__fsbChangeReportHandle. +// MutationObserver. Stores the handle under its token in +// window.__fsbChangeReportHandles so overlapping harvests stay separate. // This function is serialized and injected via chrome.scripting.executeScript; // it must be self-contained (no closures over SW scope). -function _fsbHarvestStartInPage(targetSelector) { +function _fsbHarvestStartInPage(targetSelector, token, deadline) { try { function getClassName(el) { if (!el) return ''; @@ -3586,15 +3590,31 @@ function _fsbHarvestStartInPage(targetSelector) { } return cur || target.parentElement || document.documentElement; } + // The service worker stops waiting for this injection at `deadline`. An + // observer installed after that would never be stopped. + if (Date.now() > deadline) return { ok: false, expired: true }; + const handles = window.__fsbChangeReportHandles || (window.__fsbChangeReportHandles = {}); const beforeState = captureState(); const root = resolveScope(targetSelector); + const handle = { beforeState, mutations: [], mutationCount: 0, startedAt: Date.now() }; + // A stop that never arrives must not leave the observer running: retain + // at most 5000 records and disconnect after two minutes. + handle.expiry = setTimeout(() => { + if (handle.observer) { + try { handle.observer.disconnect(); } catch (_) { /* idempotent */ } + } + if (handles[token] === handle) delete handles[token]; + }, 120000); + handles[token] = handle; if (typeof MutationObserver === 'undefined' || !root) { - window.__fsbChangeReportHandle = { beforeState, mutations: [], startedAt: Date.now(), noObserver: true }; + handle.noObserver = true; return { ok: true, beforeState }; } - const handle = { beforeState, mutations: [], startedAt: Date.now() }; const observer = new MutationObserver((records) => { - for (let i = 0; i < records.length; i++) handle.mutations.push(records[i]); + handle.mutationCount += records.length; + for (let i = 0; i < records.length && handle.mutations.length < 5000; i++) { + handle.mutations.push(records[i]); + } }); try { observer.observe(root, { @@ -3604,7 +3624,6 @@ function _fsbHarvestStartInPage(targetSelector) { }); handle.observer = observer; } catch (_) { /* observation failed; handle continues with empty mutations */ } - window.__fsbChangeReportHandle = handle; return { ok: true, beforeState }; } catch (e) { return { ok: false, error: String(e && e.message || e) }; @@ -3613,7 +3632,7 @@ function _fsbHarvestStartInPage(targetSelector) { // Page-context harvest stop: serializes mutation records, captures afterState, // disconnects the observer, returns plain-data shape buildChangeReport accepts. -function _fsbHarvestStopInPage() { +function _fsbHarvestStopInPage(token) { try { function getClassName(el) { if (!el) return ''; @@ -3694,8 +3713,11 @@ function _fsbHarvestStopInPage() { get className() { return this._className; } }; } - const handle = window.__fsbChangeReportHandle; + const handles = window.__fsbChangeReportHandles || {}; + const handle = handles[token]; if (!handle) return { ok: true, mutations: [], afterState: captureState(), settle_ms: 0 }; + delete handles[token]; + clearTimeout(handle.expiry); if (handle.observer && typeof handle.observer.disconnect === 'function') { try { handle.observer.disconnect(); } catch (_) { /* idempotent */ } } @@ -3717,8 +3739,7 @@ function _fsbHarvestStopInPage() { }); } const afterState = captureState(); - delete window.__fsbChangeReportHandle; - return { ok: true, mutations: serialized, afterState, settle_ms }; + return { ok: true, mutations: serialized, afterState, settle_ms, mutation_count: handle.mutationCount }; } catch (e) { return { ok: false, error: String(e && e.message || e) }; } @@ -3787,12 +3808,16 @@ async function _injectFn(tabId, func, args) { return null; } try { - const results = await chrome.scripting.executeScript({ + const injection = chrome.scripting.executeScript({ target: { tabId }, world: 'MAIN', func, args: args || [] }); + const results = await Promise.race([ + injection, + new Promise((_, reject) => setTimeout(() => reject(new Error('Page injection timed out')), _PAGE_INJECTION_TIMEOUT_MS)) + ]); return results && results[0] ? results[0].result : null; } catch (_) { return null; @@ -3832,12 +3857,13 @@ async function wrapWithChangeReport(ctx) { } // Gate 1: per-tool flag - const toolDef = (typeof _mcp_getToolByName === 'function') ? _mcp_getToolByName(toolName) : null; + const toolDef = (typeof _mcp_getToolByNameOrVerb === 'function') ? _mcp_getToolByNameOrVerb(toolName) : null; const flagOn = !!(toolDef && toolDef._emitChangeReport === true); // Gate 2: global toggle const globalOn = !!fsbChangeReportsEnabled; - if (!flagOn || !globalOn || !Number.isFinite(tabId)) { + if (!flagOn || !globalOn || !Number.isFinite(tabId) || + ['navigate', 'close_tab', 'refresh', 'go_back', 'go_forward', 'open_tab', 'switch_tab'].includes(toolName)) { return execute(); } @@ -3845,8 +3871,13 @@ async function wrapWithChangeReport(ctx) { // missing chrome.scripting), skip wrap and run action without change_report. const beforeUrl = await _safeGetTabUrl(tabId); const targetSelector = _resolveTargetSelector(params); - const startResult = await _injectFn(tabId, _fsbHarvestStartInPage, [targetSelector]); + const harvestToken = `${Date.now()}-${++_changeReportSeq}`; + const startResult = await _injectFn(tabId, _fsbHarvestStartInPage, + [targetSelector, harvestToken, Date.now() + _PAGE_INJECTION_TIMEOUT_MS]); const startedHarvest = !!(startResult && startResult.ok); + // A start that timed out can still run once the page is idle. Queue its + // stop behind it so that observer is torn down as soon as it exists. + if (!startedHarvest) _injectFn(tabId, _fsbHarvestStopInPage, [harvestToken]); let response; try { @@ -3875,13 +3906,13 @@ async function wrapWithChangeReport(ctx) { let stop = null; if (!crossOrigin) { - stop = await _injectFn(tabId, _fsbHarvestStopInPage, []); + stop = await _injectFn(tabId, _fsbHarvestStopInPage, [harvestToken]); } const builders = _resolveChangeReportBuilders(); if (!builders) { // Builder unavailable; clean up the page-side handle and skip. - if (!crossOrigin) await _injectFn(tabId, _fsbHarvestStopInPage, []); + if (!crossOrigin) await _injectFn(tabId, _fsbHarvestStopInPage, [harvestToken]); return response; } @@ -3889,18 +3920,20 @@ async function wrapWithChangeReport(ctx) { let afterState = (stop && stop.afterState) || { url: afterUrl }; let mutations = (stop && stop.mutations) || []; let settleMs = (stop && typeof stop.settle_ms === 'number') ? stop.settle_ms : 0; + let mutationCount = (stop && typeof stop.mutation_count === 'number') ? stop.mutation_count : undefined; if (crossOrigin) { beforeState = beforeState || { url: beforeUrl }; afterState = { url: afterUrl }; mutations = []; + mutationCount = undefined; } const raw = builders.buildChangeReport( beforeState, afterState, mutations, - { crossOrigin, settleMs } + { crossOrigin, settleMs, mutationCount } ); if (partial) raw.partial = true; const capped = builders.applyChangeReportSizeCap(raw); diff --git a/mcp/README.md b/mcp/README.md index 4c6294067..69b8add0a 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -271,6 +271,8 @@ Health check: http://127.0.0.1:7226/health ``` +`serve` accepts loopback binds only (`127.0.0.1`, `localhost`, or `::1`). Local MCP clients must send a matching loopback `Host` and no `Origin` header; browser-origin requests are rejected, including health and preflight requests. If an HTTP client fails after a server restart, reconnect it to the printed local endpoint and create a new MCP session. `/health` reports bridge topology and the attached extension identity without requiring a session. + --- ## Diagnostics @@ -292,7 +294,11 @@ npx -y fsb-mcp-server@latest doctor npx -y fsb-mcp-server@latest status --watch ``` -`doctor` reports the primary failing layer: package, bridge, extension, active tab, content script, or configuration. Only restart or reinstall the client when the reported layer points there. +`doctor` reports the primary failing layer: package, bridge, authorization, extension, active tab, content script, or configuration. Only restart or reinstall the client when the reported layer points there. + +`doctor` and `status` show the extension ID, extension version, persistent install instance ID, connection time, and normal-window count. The tab count comes from the diagnostic route. `NO_BROWSER_WINDOW` means the attached profile has no normal browser window; open one there. `ORIGIN_PIN_MISMATCH` means the bridge is pinned to a different extension ID; run `npx -y fsb-mcp-server@latest pair --reset` and pair again. The bridge serves one attached extension profile at a time and does not switch profiles automatically. MCP server and extension versions are released independently; compare their reported versions with each component's requirements. + +The bridge also keeps its own journal at `~/.fsb/agent-runtime/bridge-events.jsonl`. Everything else the bridge prints goes to the stderr of whichever server won the race for port 7225, which is often a session that has since exited, so this file is usually the only surviving record of why a socket was refused, revoked, replaced, or dropped. Repeated identical events are coalesced into one line per minute carrying a `suppressed` count, so a retry loop stays legible instead of filling the file. ### Common Failure Modes @@ -300,7 +306,10 @@ npx -y fsb-mcp-server@latest status --watch |---------|-------------| | No tools in client | Confirm the client config and restart/reload the host. | | Tools exist but all calls fail | Run `doctor` and confirm the extension is connected. | +| The extension will not attach, and reinstalling it does not help | The bridge pairing is bound to one extension id, and reinstalling as an unpacked build mints a new one, which deepens the mismatch. Check `bridge-events.jsonl` for `upgrade_rejected_origin_pin`, then run `npx -y fsb-mcp-server@latest pair --reset`. | +| A tool reports `sw_evicted` but the browser is clearly fine | Read the `disconnect_reason` alongside it. `extension_auth_revoked` means the pairing was rejected, not that the worker was evicted. | | Page reads fail | Make sure the active tab is a normal webpage, not `chrome://`, `edge://`, or the web store. | +| Page reads return `PAGE_UNRESPONSIVE` | Use `navigate` or `close_tab` to recover the hung tab. Inspect the page before repeating an action whose result says `outcome: "unknown"` and `mayHaveExecuted: true`. | | Clicks do nothing | Refresh DOM refs with `get_dom_snapshot`, then try `click_at` or `execute_js` where appropriate. | | A task is stuck | Use `get_task_status`, then `stop_task` if it is still running. | | Visual overlay remains | Send the last action with `is_final:true`, wait for the 60s idle auto-clear, or reload the tab. | diff --git a/mcp/ai/tool-definitions.cjs b/mcp/ai/tool-definitions.cjs index b37965e00..6516d1e81 100644 --- a/mcp/ai/tool-definitions.cjs +++ b/mcp/ai/tool-definitions.cjs @@ -242,12 +242,13 @@ const TOOL_REGISTRY = [ withVisualSessionFields({ name: 'type_text', - description: 'Type text into an input field by selector. When to use: to fill text inputs, search boxes, or text areas. Use clear_input first if the field already has text. Returns confirmation of typed text. Related: clear_input (clear field before typing), press_enter (submit after typing), get_dom_snapshot (find input selectors). Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64). Pass tab_id only when this agent owns multiple tabs; auto-resolves otherwise.', + description: 'Replace text in an editable field by selector. Set clear_first to false to append. Returns the rendered text after insertion. Related: clear_input, press_enter, get_dom_snapshot. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { selector: { type: 'string', description: 'CSS selector or element ref for the input field (e.g., "#email", "input[name=search]", or "e12" from get_dom_snapshot)' }, text: { type: 'string', description: 'Text to type into the field' }, + clear_first: { type: 'boolean', description: 'Replace existing text (default true); false appends at the end.' }, tab_id: { type: 'number', description: 'Optional. Tab id this action targets. Omit when the calling agent owns exactly one tab; pass to disambiguate when the agent owns multiple. Single-tab agents and legacy popup/sidepanel/autopilot do not need to pass this.' } }, required: ['selector', 'text'] @@ -869,11 +870,13 @@ const TOOL_REGISTRY = [ withVisualSessionFields({ name: 'insert_text', - description: 'Insert text at the current cursor position via CDP Input.insertText. Bypasses DOM event dispatch and directly inserts into the focused element. When to use: for canvas-based editors (Excalidraw, Google Docs, Slack) where type_text does not work because there is no real input element. The element must already be focused or in edit mode (use double_click_at or click_at first). Related: type_text (for real DOM input fields), double_click_at (enter edit mode in canvas editors before inserting text), click_at (focus canvas element before inserting). Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64). Pass tab_id only when this agent owns multiple tabs; auto-resolves otherwise.', + description: 'Insert text through CDP at the caret by default. Use position end to append or replace_all to replace an editable field; selector can identify that field. Canvas editors require focus first. Related: type_text, double_click_at, click_at. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to insert at current cursor position via CDP' }, + position: { type: 'string', enum: ['caret', 'end', 'replace_all'], description: 'Insertion position; defaults to caret.' }, + selector: { type: 'string', description: 'Optional CSS selector identifying exactly one editable field.' }, tab_id: { type: 'number', description: 'Optional. Tab id this action targets. Omit when the calling agent owns exactly one tab; pass to disambiguate when the agent owns multiple. Single-tab agents and legacy popup/sidepanel/autopilot do not need to pass this.' } }, required: ['text'] @@ -1459,6 +1462,13 @@ function getToolByName(name) { return TOOL_REGISTRY.find(t => t.name === name) || null; } +/** Resolve the public MCP name or the content/CDP verb sent on the bridge. */ +function getToolByNameOrVerb(nameOrVerb) { + const name = typeof nameOrVerb === 'string' ? nameOrVerb.trim() : ''; + if (!name) return null; + return getToolByName(name) || getToolByName(_iconVerbMap().get(name)); +} + /** * Get all read-only tools (those that bypass the mutation queue). * @returns {ToolDefinition[]} Array of read-only tool definitions @@ -1518,7 +1528,7 @@ function resolveIconActivity(nameOrVerb) { if (!raw) return 'sweep'; const name = _iconVerbMap().get(raw) || raw; if (name === 'invoke_capability') return null; - const def = getToolByName(name); + const def = getToolByNameOrVerb(raw); if ((def && def._readOnly === true) || ICON_READ_ONLY_EXTRAS.has(name)) return 'orbit'; return 'sweep'; } @@ -1533,6 +1543,7 @@ if (typeof module !== 'undefined' && module.exports) { TOOL_REGISTRY, resolveIconActivity, getToolByName, + getToolByNameOrVerb, getReadOnlyTools, getToolsByRoute, VISUAL_SESSION_FIELDS, diff --git a/mcp/build/index.js b/mcp/build/index.js index f6a443ff1..5cfcd12f0 100755 --- a/mcp/build/index.js +++ b/mcp/build/index.js @@ -8,6 +8,7 @@ import { DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, FSB_EXTENSION_BRIDGE_URL, FSB_MCP import { getSetupSections, runInstall, runUninstall } from './install.js'; import { createProductionNativeHostCliOperations, inspectProductionNativeHost, } from './native-host-production.js'; import { pushMcpClientInventory } from './client-inventory.js'; +import { shutdownWhenStdinEnds } from './stdin-shutdown.js'; import { FSB_EXT_PROTOCOL, formatPairingCode, readBridgeAuthState, resetBridgePairing, rotateBridgeSessionSecret, } from './bridge-auth.js'; const productionNativeHostCliOperations = createProductionNativeHostCliOperations(); const productionNativeHostDiagnostics = Object.freeze({ @@ -167,6 +168,19 @@ export function buildCompactStatusFields(diagnostics, nowMs = Date.now()) { ['Relays', String(diagnostics.relayCount)], ['Disconnect', diagnostics.lastDisconnectReason ?? 'none'], ['Layer', diagnostics.diagnosticLayer], + ...(diagnostics.diagnosticCode ? [['Code', diagnostics.diagnosticCode]] : []), + ]; +} +function formatExtensionAttachment(diagnostics) { + const attachment = diagnostics.extensionAttachment; + if (!attachment) + return []; + return [ + `Extension ID: ${attachment.extensionId}`, + `Extension version: ${attachment.extensionVersion}`, + `Install instance: ${attachment.installInstanceId}`, + `Normal windows: ${attachment.normalWindowCount}`, + `Connected at: ${attachment.connectedAt ?? 'not reported'}`, ]; } function formatFieldLines(fields) { @@ -189,6 +203,7 @@ export function formatStatus(diagnostics) { const model = diagnostics.extensionConfig.modelName ?? 'unknown'; lines.push(`Extension model: ${provider} / ${model}`); } + lines.push(...formatExtensionAttachment(diagnostics)); if (diagnostics.tabsSummary) { lines.push(`Open tabs: ${diagnostics.tabsSummary.totalTabs}`); lines.push(`Active tab ID: ${diagnostics.tabsSummary.activeTabId ?? 'none'}`); @@ -210,6 +225,7 @@ export function formatWatchSnapshot(diagnostics) { if (diagnostics.activeTab.url) { lines.push(`Active page: ${diagnostics.activeTab.pageType} (${diagnostics.activeTab.url})`); } + lines.push(...formatExtensionAttachment(diagnostics)); if (diagnostics.probeNotes && diagnostics.probeNotes.length > 0) { lines.push(`Note: ${diagnostics.probeNotes[0].message}`); } @@ -222,6 +238,7 @@ export function formatDoctor(diagnostics) { `Why: ${diagnostics.diagnosticWhy}`, `Next action: ${diagnostics.nextAction}`, ...formatFieldLines(buildCompactStatusFields(diagnostics)), + ...formatExtensionAttachment(diagnostics), ]; if (diagnostics.activeTab.url) { lines.push(`Active page: ${diagnostics.activeTab.pageType} (${diagnostics.activeTab.url})`); @@ -305,6 +322,12 @@ async function runStdioServer() { }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); + // Without this, a host that exits by closing the pipe leaves the bridge + // running -- and holding the port -- forever. + shutdownWhenStdinEnds(process.stdin, () => { + console.error('[FSB MCP] stdin closed by the host; shutting down.'); + shutdown(); + }); } async function runHttpMode(flags) { const host = readStringFlag(flags, 'host', DEFAULT_HTTP_HOST); @@ -314,7 +337,8 @@ async function runHttpMode(flags) { port, dependencies: { prepareBridgeAuth: () => { - rotateBridgeSessionSecret(); + if (!readBridgeAuthState()) + rotateBridgeSessionSecret(); }, }, }); diff --git a/mcp/src/bridge-events.ts b/mcp/src/bridge-events.ts new file mode 100644 index 000000000..8db7d05bd --- /dev/null +++ b/mcp/src/bridge-events.ts @@ -0,0 +1,151 @@ +/** + * Every `[FSB Bridge]` line goes to console.error, which is the stderr of + * whichever server won the race for the bridge port -- often a session that has + * since died, leaving its output pointed at a socket nobody can read. A lockout + * that lasts a day therefore leaves nothing on disk to look at afterwards, and + * the failure has to be reconstructed from whichever MCP host happened to + * capture its child's stderr. + * + * This is the bridge's own journal: a closed roster of transport events, each + * carrying only identifiers that are already visible elsewhere -- an extension + * id from the browser's extensions page, a close code, an instance id. + * Credentials, headers, and message payloads never reach it. + * + * Coalesced on purpose. The events worth recording are exactly the ones that + * fire in hot retry loops, so an uncoalesced journal would reproduce the + * pathology it exists to explain. + */ + +import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +const DIRECTORY_MODE = 0o700; +const FILE_MODE = 0o600; +const LOG_FILENAME = 'bridge-events.jsonl'; +const ROTATED_FILENAME = 'bridge-events.1.jsonl'; +const MAX_LOG_BYTES = 1024 * 1024; +const COALESCE_WINDOW_MS = 60_000; +const MAX_COALESCE_KEYS = 32; +const INSTANCE_PATTERN = /^[0-9a-f]{4,32}$/; +const ORIGIN_PATTERN = /^chrome-extension:\/\/[A-Za-z0-9._-]{1,64}$/; +const CODE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; + +const EVENTS = Object.freeze([ + 'upgrade_rejected_host', + 'upgrade_rejected_origin_pin', + 'upgrade_accepted_unauthorized', + 'ext_authority_revoked', + 'extension_slot_refused', + 'extension_replaced', + 'extension_reaped', + 'extension_closed', + 'hub_server_error', + 'hub_listener_lost', +] as const); + +export type BridgeLogEvent = typeof EVENTS[number]; + +export interface BridgeLogRecord { + readonly event: BridgeLogEvent; + readonly instanceId?: string; + readonly origin?: string | null; + readonly pinnedOrigin?: string | null; + readonly closeCode?: number; + readonly reason?: string; +} + +const coalesced = new Map(); + +export function getBridgeLogPath(homeDir = homedir()): string { + return join(homeDir, '.fsb', 'agent-runtime', LOG_FILENAME); +} + +function boundedOrigin(value: unknown): string | null { + return typeof value === 'string' && ORIGIN_PATTERN.test(value) ? value : null; +} + +/** Drop anything that is not an own scalar from the roster above. */ +function projectRecord(record: BridgeLogRecord): Record | null { + if (!EVENTS.includes(record.event)) return null; + const projected: Record = { event: record.event }; + if ( + typeof record.instanceId === 'string' + && INSTANCE_PATTERN.test(record.instanceId) + ) projected.instanceId = record.instanceId; + const origin = boundedOrigin(record.origin); + if (origin) projected.origin = origin; + const pinnedOrigin = boundedOrigin(record.pinnedOrigin); + if (pinnedOrigin) projected.pinnedOrigin = pinnedOrigin; + if ( + typeof record.closeCode === 'number' + && Number.isSafeInteger(record.closeCode) + && record.closeCode >= 1000 + && record.closeCode <= 4999 + ) projected.closeCode = record.closeCode; + if ( + typeof record.reason === 'string' + && CODE_PATTERN.test(record.reason) + ) projected.reason = record.reason; + return projected; +} + +/** + * Synchronous and never throws -- a diagnostics file must not be able to fail a + * bridge operation. + * + * Returns true when a line was actually written and false when it was coalesced + * away, so callers can gate their own console output on the same decision and a + * retry loop cannot flood stderr either. + */ +export function logBridgeEvent( + record: BridgeLogRecord, + options: Readonly<{ rootPath?: string; now?: () => number }> = {}, +): boolean { + try { + const projected = projectRecord(record); + if (!projected) return false; + + const stamp = options.now ? options.now() : Date.now(); + const key = `${projected.event as string}|${(projected.origin as string) ?? ''}`; + const previous = coalesced.get(key); + if (previous && stamp - previous.at < COALESCE_WINDOW_MS) { + previous.suppressed += 1; + return false; + } + if (previous && previous.suppressed > 0) projected.suppressed = previous.suppressed; + if (!previous && coalesced.size >= MAX_COALESCE_KEYS) coalesced.clear(); + coalesced.set(key, { at: stamp, suppressed: 0 }); + + const requested = options.rootPath ?? join(homedir(), '.fsb', 'agent-runtime'); + if (!isAbsolute(requested) || requested.includes('\0')) return false; + const root = resolve(requested); + const logPath = join(root, LOG_FILENAME); + mkdirSync(root, { recursive: true, mode: DIRECTORY_MODE }); + try { + if (statSync(logPath).size > MAX_LOG_BYTES) { + renameSync(logPath, join(root, ROTATED_FILENAME)); + } + } catch { + // No log yet, or it cannot be rotated; appending below is still correct. + } + const line = `${JSON.stringify({ + ts: new Date(Number.isSafeInteger(stamp) ? stamp : 0).toISOString(), + ...projected, + })}\n`; + appendFileSync(logPath, line, { encoding: 'utf8', mode: FILE_MODE }); + return true; + } catch { + // Diagnostics are best-effort and never affect bridge behaviour. + return false; + } +} + +/** Test seam: the coalescer is module state and outlives a single case. */ +export function _resetBridgeEventCoalescing(): void { + coalesced.clear(); +} + +export const BRIDGE_LOG_FILENAME = LOG_FILENAME; +export const BRIDGE_LOG_MAX_BYTES = MAX_LOG_BYTES; +export const BRIDGE_LOG_COALESCE_WINDOW_MS = COALESCE_WINDOW_MS; diff --git a/mcp/src/bridge.ts b/mcp/src/bridge.ts index 2a98fc408..46e6e34f7 100644 --- a/mcp/src/bridge.ts +++ b/mcp/src/bridge.ts @@ -8,6 +8,7 @@ import type { BridgeCapability, BridgeOptions, BridgeTopologyState, + ExtensionAttachmentState, ExtEvent, ExtRequest, ExtResponse, @@ -25,6 +26,7 @@ import { bindAllowedExtensionOrigin, readBridgeAuthState, } from './bridge-auth.js'; +import { logBridgeEvent } from './bridge-events.js'; import { makeExtError, parseExtFrame } from './ext-protocol.js'; interface PendingRequest { @@ -71,15 +73,35 @@ const BRIDGE_DISCONNECT_MESSAGES = new Set([ 'Lost connection to hub', ]); +const BRIDGE_DISCONNECT_REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; + const HEARTBEAT_NONCE_MIN_LENGTH = 16; const HEARTBEAT_NONCE_MAX_LENGTH = 64; const HEARTBEAT_NONCE_PATTERN = /^[A-Za-z0-9_-]+$/; +// The specific cause travels on a property, never in the message. Several very +// different failures share 'Extension disconnected' -- a real MV3 eviction, an +// authorization revocation, a reaped zombie socket -- and they all want the same +// recovery, so the message must stay in BRIDGE_DISCONNECT_MESSAGES above. Only +// the reporting differs, which is what this property is for. Moving a cause into +// the message would silently disable sw_evicted recovery and no test would catch it. +export interface BridgeDisconnectError extends Error { + bridgeDisconnectReason?: string; +} + export function isBridgeDisconnectError(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err ?? ''); return BRIDGE_DISCONNECT_MESSAGES.has(msg); } +/** The cause behind a bridge-disconnect rejection. Reporting only; never a branch. */ +export function bridgeDisconnectReason(err: unknown): string | null { + const reason = (err as BridgeDisconnectError | null)?.bridgeDisconnectReason; + return typeof reason === 'string' && BRIDGE_DISCONNECT_REASON_PATTERN.test(reason) + ? reason + : null; +} + export class WebSocketBridge { // Identity private instanceId: string; @@ -88,6 +110,8 @@ export class WebSocketBridge { private host: string; private handshakeTimeoutMs: number; private relayHandshakeTimeoutMs: number; + private extensionPingIntervalMs: number; + private extensionHeartbeatTimeoutMs: number; private promotionJitterMs: number; private maxReconnectDelayMs: number; private allowedBrowserOrigins: string[]; @@ -104,6 +128,7 @@ export class WebSocketBridge { private messageOrigin = new Map(); // msgId -> instanceId | "local" private handshakeTimers = new Map>(); private acceptedSocketMetadata = new WeakMap(); + private extensionLivenessTimer: ReturnType | null = null; // Relay mode state private hubConnection: WebSocket | null = null; @@ -111,6 +136,7 @@ export class WebSocketBridge { private reconnectTimer: ReturnType | null = null; private reconnectDelay = 0; private intentionalClose = false; + private promotionInFlight = false; // Shared state private pendingRequests = new Map(); @@ -122,7 +148,10 @@ export class WebSocketBridge { private relayExtensionConnected = false; private relayCount = 0; private lastExtensionHeartbeatAt: number | null = null; + private extensionHeartbeatCount = 0; + private extensionCloseCause: string | null = null; private lastDisconnectReason: string | null = null; + private extensionAttachment: ExtensionAttachmentState | null = null; constructor(options: BridgeOptions = {}) { this.port = options.port ?? 7225; @@ -135,6 +164,11 @@ export class WebSocketBridge { this.instanceId = options.instanceId ?? randomBytes(4).toString('hex'); this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 2_000; this.relayHandshakeTimeoutMs = options.relayHandshakeTimeoutMs ?? 5_000; + // 30s is deliberately not harmonic with the extension's own 25s mcp:ping, + // so the two heartbeats interleave instead of beating against each other. + // 90s is 3x that ping, so two consecutive extension misses are tolerated. + this.extensionPingIntervalMs = options.extensionPingIntervalMs ?? 30_000; + this.extensionHeartbeatTimeoutMs = options.extensionHeartbeatTimeoutMs ?? 90_000; this.promotionJitterMs = options.promotionJitterMs ?? 500; this.maxReconnectDelayMs = options.maxReconnectDelayMs ?? 30_000; this.allowedBrowserOrigins = options.allowedBrowserOrigins ?? ['chrome-extension://']; @@ -195,28 +229,7 @@ export class WebSocketBridge { } if (this.mode === 'hub') { - // Close all relay clients - for (const [id, ws] of this.relayClients) { - ws.close(); - this.relayClients.delete(id); - this.relayCapabilities.delete(id); - } - // Close extension connection - if (this.extensionClient) { - this.extensionClient.close(); - this.extensionClient = null; - } - // Close server - if (this.wss) { - this._closeHubServers(); - } else if (this.httpServer) { - this._closeHubServers(); - } - // Clean up handshake timers - for (const [, timer] of this.handshakeTimers) { - clearTimeout(timer); - } - this.handshakeTimers.clear(); + this._closeHubSockets(); } else if (this.mode === 'relay') { if (this.hubConnection) { this.hubConnection.close(); @@ -229,12 +242,7 @@ export class WebSocketBridge { // the resulting `Error('Bridge disconnected')` and resolves with sw_evicted: true // per CONTEXT.md D-05. Removing the rejection would cause sendAndWait Promises // to hang indefinitely on bridge disconnect. - // Reject all pending requests - for (const [id, pending] of this.pendingRequests) { - clearTimeout(pending.timeout); - pending.reject(new Error('Bridge disconnected')); - this.pendingRequests.delete(id); - } + this._rejectPendingRequests('Bridge disconnected'); this.progressListeners.clear(); this.messageOrigin.clear(); this.relayCapabilities.clear(); @@ -246,6 +254,9 @@ export class WebSocketBridge { this.relayExtensionConnected = false; this.relayCount = 0; this.lastExtensionHeartbeatAt = null; + this.extensionAttachment = null; + this.extensionHeartbeatCount = 0; + this.extensionCloseCause = null; this.mode = 'disconnected'; console.error(`[FSB Bridge ${this.instanceId}] Disconnected`); } @@ -326,6 +337,7 @@ export class WebSocketBridge { activeHubInstanceId: this.mode === 'hub' ? this.instanceId : this.activeHubInstanceId, lastExtensionHeartbeatAt: this.lastExtensionHeartbeatAt, lastDisconnectReason: this.lastDisconnectReason, + extensionAttachment: this.extensionAttachment, }; } @@ -382,15 +394,61 @@ export class WebSocketBridge { }); httpServer.on('error', (err: NodeJS.ErrnoException) => { - this._closeHubServers(); - this.hubConnected = false; if (!startupSettled) { startupSettled = true; + this._closeHubServers(); + this.hubConnected = false; reject(err); - } else { - this.lastDisconnectReason = 'hub_server_error'; - console.error(`[FSB Bridge ${this.instanceId}] Hub HTTP server error:`, err.message); + return; } + + // Post-startup the instance fields may already belong to a newer + // _startAsHub. A late error from an older server must close only that + // server -- otherwise it tears down a healthy listener someone else + // just bound. + if (this.httpServer !== httpServer) { + try { + wss.close(); + } catch { + // A noServer WebSocketServer may not have accepted a socket yet. + } + if (httpServer.listening) { + try { + httpServer.close(); + } catch { + // The server may already be closing after the error. + } + } + return; + } + + const errorCode = typeof err.code === 'string' ? err.code.toLowerCase() : undefined; + // Separate events so a burst of accept failures cannot coalesce away + // the one line that records the listener actually dying. + const logged = logBridgeEvent({ + event: httpServer.listening ? 'hub_server_error' : 'hub_listener_lost', + instanceId: this.instanceId, + reason: errorCode, + }); + + // Node keeps the listening handle open after an accept failure such as + // EMFILE, so the hub is still serving. Tearing it down here would turn + // a transient error into an outage. Such failures can repeat once per + // incoming connection, hence the coalesced log. + if (httpServer.listening) { + if (logged) { + console.error(`[FSB Bridge ${this.instanceId}] Hub HTTP server error (still listening):`, err.message); + } + return; + } + + // The listener is really gone. Staying in 'hub' mode would leave the + // extension and every relay attached to a hub that accepts nobody and + // never demotes, so drop them all and compete for the port again -- + // the same path a relay takes when its hub disappears. + console.error(`[FSB Bridge ${this.instanceId}] Hub HTTP server error, listener lost:`, err.message); + this._abandonHub('hub_server_error'); + void this._attemptPromotion(); }); wss.on('connection', (ws: WsWebSocket) => { @@ -427,6 +485,61 @@ export class WebSocketBridge { } } + /** Close every socket and server this process holds as hub. */ + private _closeHubSockets(): void { + for (const [id, ws] of this.relayClients) { + ws.close(); + this.relayClients.delete(id); + this.relayCapabilities.delete(id); + } + this._stopExtensionLiveness(); + // Cleared before close() so the socket's own close handler sees a stale + // client and leaves the bridge state to the caller. + const extensionClient = this.extensionClient; + this.extensionClient = null; + extensionClient?.close(); + this._closeHubServers(); + for (const [, timer] of this.handshakeTimers) { + clearTimeout(timer); + } + this.handshakeTimers.clear(); + } + + private _rejectPendingRequests(message: string, reason?: string): void { + for (const [id, pending] of this.pendingRequests) { + clearTimeout(pending.timeout); + const error = new Error(message) as BridgeDisconnectError; + if (reason) error.bridgeDisconnectReason = reason; + pending.reject(error); + this.pendingRequests.delete(id); + } + } + + /** + * Leave hub mode without shutting down: unlike disconnect() this is not + * intentional, so the caller is expected to compete for the port again. + */ + private _abandonHub(reason: string): void { + for (const route of [...this.activeExtRequests.values()]) { + this._abortExtRoute(route); + } + this._closeHubSockets(); + this._rejectPendingRequests('Bridge disconnected', reason); + this.progressListeners.clear(); + this.messageOrigin.clear(); + this.relayCapabilities.clear(); + this.activeExtRequests.clear(); + this.connected = false; + this.hubConnected = false; + this.activeHubInstanceId = null; + this.lastExtensionHeartbeatAt = null; + this.extensionAttachment = null; + this.extensionHeartbeatCount = 0; + this.extensionCloseCause = null; + this.mode = 'disconnected'; + this.lastDisconnectReason = reason; + } + private _activePort(): number { const address = this.httpServer?.address(); return address && typeof address === 'object' ? address.port : this.port; @@ -500,13 +613,35 @@ export class WebSocketBridge { } private _classifyUpgrade(req: IncomingMessage): AcceptedSocketMetadata | null { - if (!this._hasAllowedHost(req)) return null; + if (!this._hasAllowedHost(req)) { + logBridgeEvent({ event: 'upgrade_rejected_host', instanceId: this.instanceId }); + return null; + } const browserOrigin = this._parseBrowserOrigin(req); if (browserOrigin === false) return null; const state = readBridgeAuthState(); if (browserOrigin && state?.allowedExtensionOrigin && state.allowedExtensionOrigin !== browserOrigin) { + // This rejection is a bare 403 with no WebSocket, no close code and no + // reason, so the extension sees an indistinguishable generic failure and + // retries forever. Naming the pin and its cure here is the only place the + // answer can surface. Gated on the journal's write decision so a retry + // loop cannot flood stderr. + if (logBridgeEvent({ + event: 'upgrade_rejected_origin_pin', + instanceId: this.instanceId, + origin: browserOrigin, + pinnedOrigin: state.allowedExtensionOrigin, + })) { + console.error( + `[FSB Bridge ${this.instanceId}] Refusing upgrade from ${browserOrigin}: ` + + `bridge pairing is bound to ${state.allowedExtensionOrigin}. ` + + 'Cure: npx -y fsb-mcp-server@latest pair --reset', + ); + } + this.lastDisconnectReason = 'extension_origin_pin_mismatch'; + this._broadcastRelayState(); return null; } @@ -526,6 +661,18 @@ export class WebSocketBridge { } } + if (browserOrigin && !extAuthorized) { + // Worth recording precisely because it is the normal, healthy state for + // an unpaired extension: ordinary browser tools work fine, only the + // reverse channel is gated. From the outside it is indistinguishable + // from the broken case, so the journal is where the difference lives. + logBridgeEvent({ + event: 'upgrade_accepted_unauthorized', + instanceId: this.instanceId, + origin: browserOrigin, + }); + } + return { browserOrigin: browserOrigin || null, extAuthorized, @@ -589,7 +736,16 @@ export class WebSocketBridge { if (metadata.browserOrigin) { if (this.extensionClient && !this._isCurrentExtAuthority(ws)) { - console.error(`[FSB Bridge ${this.instanceId}] Unprivileged extension candidate cannot replace active extension`); + // Coalesced: an extension that keeps retrying lands here once per + // attempt, which is how a single lockout produced tens of thousands of + // identical lines in the field. + if (logBridgeEvent({ + event: 'extension_slot_refused', + instanceId: this.instanceId, + origin: metadata.browserOrigin, + })) { + console.error(`[FSB Bridge ${this.instanceId}] Unprivileged extension candidate cannot replace active extension`); + } ws.close(1008, 'Extension authorization required'); return; } @@ -636,9 +792,17 @@ export class WebSocketBridge { } private _registerExtensionClient(ws: WsWebSocket): void { + this._stopExtensionLiveness(); + if (this.extensionClient) { console.error(`[FSB Bridge ${this.instanceId}] New extension connected, closing previous`); this.lastDisconnectReason = 'extension_replaced'; + this.extensionCloseCause = 'extension_replaced'; + logBridgeEvent({ + event: 'extension_replaced', + instanceId: this.instanceId, + origin: this.acceptedSocketMetadata.get(this.extensionClient)?.browserOrigin ?? null, + }); this._abortRoutesForOrigin(this.extensionClient); this.extensionClient.close(); } @@ -646,9 +810,13 @@ export class WebSocketBridge { this.extensionClient = ws; this.connected = true; this.lastExtensionHeartbeatAt = Date.now(); + this.extensionHeartbeatCount = 0; this.lastDisconnectReason = null; + this.extensionAttachment = null; + this.extensionCloseCause = null; console.error(`[FSB Bridge ${this.instanceId}] Extension connected`); this._broadcastRelayState(); + this._startExtensionLiveness(ws); // Replace the temporary message handler with the real one ws.removeAllListeners('message'); @@ -656,19 +824,41 @@ export class WebSocketBridge { this._handleExtensionMessage(ws, typeof data === 'string' ? data : data.toString()); }); - ws.on('close', () => { + ws.on('close', (code: number) => { if (this.extensionClient !== ws) return; - - console.error(`[FSB Bridge ${this.instanceId}] Extension disconnected`); + this._stopExtensionLiveness(); + + // The rejection message below stays literal so the sw_evicted recovery + // keeps arming -- that recovery (reconnect, then fetch the persisted + // snapshot) is right for an authorization close too, because the task + // really was interrupted. The specific cause rides on a property of the + // Error instead, so callers can report what actually happened without + // any recovery branch changing. + const cause = this.extensionCloseCause + ?? (code === 1008 ? 'extension_policy_closed' : 'extension_disconnected'); + this.extensionCloseCause = null; + + console.error(`[FSB Bridge ${this.instanceId}] Extension disconnected (${cause}, code=${code})`); + logBridgeEvent({ + event: 'extension_closed', + instanceId: this.instanceId, + origin: this.acceptedSocketMetadata.get(ws)?.browserOrigin ?? null, + closeCode: code, + reason: cause, + }); this.extensionClient = null; this.connected = false; - this.lastDisconnectReason = 'extension_disconnected'; + this.lastDisconnectReason = cause; this.lastExtensionHeartbeatAt = null; + this.extensionAttachment = null; + this.extensionHeartbeatCount = 0; // Reject all pending local requests for (const [id, pending] of this.pendingRequests) { clearTimeout(pending.timeout); - pending.reject(new Error('Extension disconnected')); + const error = new Error('Extension disconnected') as BridgeDisconnectError; + error.bridgeDisconnectReason = cause; + pending.reject(error); this.pendingRequests.delete(id); } this.progressListeners.clear(); @@ -691,6 +881,97 @@ export class WebSocketBridge { }); } + /** + * The extension slot is freed only by the incumbent socket's own close event, + * so a peer that stops answering without ever sending FIN holds it forever. + * Two independent tiers close that hole: + * + * Tier A (transport) -- protocol ping/pong. Catches half-open sockets and + * dead TCP peers, and cannot false-positive because every RFC 6455 + * endpoint must answer a ping. + * Tier B (application) -- the extension's own mcp:ping. Catches a live + * transport whose service worker is gone; a browser answers protocol + * pings below the worker, so a pong alone does not prove the worker is + * running. Armed only once this socket has sent at least one mcp:ping, + * so an older extension build that never sends one is governed by Tier A + * alone and is never terminated merely for staying quiet. + */ + private _startExtensionLiveness(ws: WsWebSocket): void { + let awaitingPong = false; + let sawAppHeartbeat = false; + + ws.on('pong', () => { + awaitingPong = false; + }); + + const timer = setInterval(() => { + if (this.extensionClient !== ws) { + this._stopExtensionLiveness(); + return; + } + if (ws.readyState !== WebSocket.OPEN) { + this._reapExtensionClient(ws, 'transport_closed'); + return; + } + + const heartbeatAt = this.lastExtensionHeartbeatAt; + // Counted rather than inferred from timestamps: registration and a fast + // first ping can land in the same millisecond, and a comparison would + // then never arm this tier at all. + if (this.extensionHeartbeatCount > 0) sawAppHeartbeat = true; + + if (awaitingPong) { + this._reapExtensionClient(ws, 'pong_timeout'); + return; + } + if ( + sawAppHeartbeat + && heartbeatAt !== null + && Date.now() - heartbeatAt > this.extensionHeartbeatTimeoutMs + ) { + this._reapExtensionClient(ws, 'heartbeat_timeout'); + return; + } + + awaitingPong = true; + try { + ws.ping(); + } catch { + // A socket that cannot even be pinged is already gone; the readyState + // check on the next tick reaps it. + awaitingPong = false; + } + }, this.extensionPingIntervalMs); + + timer.unref?.(); + this.extensionLivenessTimer = timer; + } + + private _stopExtensionLiveness(): void { + if (!this.extensionLivenessTimer) return; + clearInterval(this.extensionLivenessTimer); + this.extensionLivenessTimer = null; + } + + /** + * terminate() rather than close(): a peer that has stopped answering will not + * complete a closing handshake either. terminate() still fires the socket's + * own 'close' handler, which is the one place that frees the slot. + */ + private _reapExtensionClient(ws: WsWebSocket, reason: string): void { + this._stopExtensionLiveness(); + if (this.extensionClient !== ws) return; + console.error(`[FSB Bridge ${this.instanceId}] Extension liveness lost (${reason}), terminating socket`); + logBridgeEvent({ + event: 'extension_reaped', + instanceId: this.instanceId, + origin: this.acceptedSocketMetadata.get(ws)?.browserOrigin ?? null, + reason, + }); + this.extensionCloseCause = `extension_reaped_${reason}`; + ws.terminate(); + } + private _registerRelayClient(ws: WsWebSocket, hello: RelayHello): void { const clientId = hello.instanceId; const capabilities = new Set( @@ -718,6 +999,7 @@ export class WebSocketBridge { relayCount: this.relayClients.size, lastExtensionHeartbeatAt: this.lastExtensionHeartbeatAt, lastDisconnectReason: this.lastDisconnectReason, + extensionAttachment: this.extensionAttachment, }; ws.send(JSON.stringify(welcome)); this._broadcastRelayState(); @@ -758,6 +1040,7 @@ export class WebSocketBridge { relayCount: this.relayClients.size, lastExtensionHeartbeatAt: this.lastExtensionHeartbeatAt, lastDisconnectReason: this.lastDisconnectReason, + extensionAttachment: this.extensionAttachment, }; } @@ -783,6 +1066,7 @@ export class WebSocketBridge { if (!heartbeat) return; const heartbeatAt = Date.now(); this.lastExtensionHeartbeatAt = heartbeatAt; + this.extensionHeartbeatCount += 1; if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(heartbeat.nonce === undefined ? { type: 'mcp:pong', ts: heartbeatAt } @@ -791,6 +1075,20 @@ export class WebSocketBridge { this._broadcastRelayState(); return; } + if (parsed.type === 'mcp:extension-state' && this.extensionClient === ws) { + const { extensionId, extensionVersion, installInstanceId, normalWindowCount, connectedAt } = parsed; + if (typeof extensionId === 'string' && /^[a-p]{32}$/.test(extensionId) + && typeof extensionVersion === 'string' && extensionVersion.length <= 64 + && typeof installInstanceId === 'string' && installInstanceId.length <= 128 + && Number.isInteger(normalWindowCount) && (normalWindowCount as number) >= 0 + && (normalWindowCount as number) <= 1000 + && (connectedAt === null || (typeof connectedAt === 'string' && connectedAt.length <= 64))) { + this.extensionAttachment = { extensionId, extensionVersion, installInstanceId, + normalWindowCount: normalWindowCount as number, connectedAt: connectedAt as string | null }; + this._broadcastRelayState(); + } + return; + } } catch { console.error(`[FSB Bridge ${this.instanceId}] Failed to parse extension message`); return; @@ -900,6 +1198,12 @@ export class WebSocketBridge { const metadata = this.acceptedSocketMetadata.get(ws); if (!metadata) return; metadata.extAuthorized = false; + if (this.extensionClient === ws) this.extensionCloseCause = 'extension_auth_revoked'; + logBridgeEvent({ + event: 'ext_authority_revoked', + instanceId: this.instanceId, + origin: metadata.browserOrigin, + }); if (!metadata.unauthorizedSent && ws.readyState === WebSocket.OPEN) { metadata.unauthorizedSent = true; const id = typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 200 @@ -1142,6 +1446,7 @@ export class WebSocketBridge { this.lastDisconnectReason = typeof state.lastDisconnectReason === 'string' ? state.lastDisconnectReason : null; + this.extensionAttachment = state.extensionAttachment ?? null; this.connected = this.relayExtensionConnected; } @@ -1392,31 +1697,39 @@ export class WebSocketBridge { * If the port is still taken (another relay won the race), reconnect as relay. */ private async _attemptPromotion(): Promise { - if (this.intentionalClose) return; + // The jitter below means two overlapping callers would both wake up and + // race to bind the same port, and the loser's failure path schedules yet + // another attempt. + if (this.intentionalClose || this.promotionInFlight) return; + this.promotionInFlight = true; - // Random jitter to avoid thundering herd - const jitter = Math.floor(Math.random() * this.promotionJitterMs); - console.error(`[FSB Bridge ${this.instanceId}] Attempting promotion in ${jitter}ms`); + try { + // Random jitter to avoid thundering herd + const jitter = Math.floor(Math.random() * this.promotionJitterMs); + console.error(`[FSB Bridge ${this.instanceId}] Attempting promotion in ${jitter}ms`); - await new Promise(r => setTimeout(r, jitter)); + await new Promise(r => setTimeout(r, jitter)); - if (this.intentionalClose) return; + if (this.intentionalClose) return; - try { - await this._startAsHub(); - this.hubConnected = true; - this.activeHubInstanceId = this.instanceId; - this.relayExtensionConnected = false; - this.relayCount = 0; - console.error(`[FSB Bridge ${this.instanceId}] Promoted to hub mode`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === 'EADDRINUSE') { - console.error(`[FSB Bridge ${this.instanceId}] Promotion failed (port taken), reconnecting as relay`); - this._scheduleRelayReconnect(); - } else { - console.error(`[FSB Bridge ${this.instanceId}] Promotion failed:`, err); - this._scheduleRelayReconnect(); + try { + await this._startAsHub(); + this.hubConnected = true; + this.activeHubInstanceId = this.instanceId; + this.relayExtensionConnected = false; + this.relayCount = 0; + console.error(`[FSB Bridge ${this.instanceId}] Promoted to hub mode`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'EADDRINUSE') { + console.error(`[FSB Bridge ${this.instanceId}] Promotion failed (port taken), reconnecting as relay`); + this._scheduleRelayReconnect(); + } else { + console.error(`[FSB Bridge ${this.instanceId}] Promotion failed:`, err); + this._scheduleRelayReconnect(); + } } + } finally { + this.promotionInFlight = false; } } diff --git a/mcp/src/diagnostics.ts b/mcp/src/diagnostics.ts index b864c4e71..18047226b 100644 --- a/mcp/src/diagnostics.ts +++ b/mcp/src/diagnostics.ts @@ -18,7 +18,7 @@ import { createProductionAdapterRegistry, type AgentProviderRegistry, } from './agent-providers/registry.js'; -import type { BridgeTopologyState } from './types.js'; +import type { BridgeTopologyState, ExtensionAttachmentState } from './types.js'; import { DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, @@ -41,7 +41,9 @@ export type BridgeDiagnosticLayer = | 'package' | 'config' | 'bridge' + | 'auth' | 'extension' + | 'no_browser_window' | 'content_script' | 'tool_routing' | 'healthy'; @@ -144,10 +146,12 @@ export type BridgeDiagnostics = { bridgeAuthMetadata: BridgeAuthDoctorMetadata; nativeHost: NativeHostDoctor; bridgeClient?: Record | null; + extensionAttachment: ExtensionAttachmentState | null; extensionConfig?: Record | null; tabsSummary?: { totalTabs: number; activeTabId: number | null }; probeNotes?: BridgeDiagnosticNote[]; diagnosticLayer: BridgeDiagnosticLayer; + diagnosticCode?: string | null; diagnosticWhy: string; nextAction: string; error?: string; @@ -166,11 +170,21 @@ export interface BridgeDiagnosticsDependencies { readonly now?: () => number; } +// Disconnect reasons the bridge reports when it refused or revoked authority, +// as opposed to the extension simply not being there. +const AUTH_DISCONNECT_REASONS: ReadonlySet = new Set([ + 'extension_auth_revoked', + 'extension_policy_closed', + 'extension_origin_pin_mismatch', +]); + export const DIAGNOSTIC_LAYER_LABELS: Record = { package: 'Package / version parity', config: 'Configuration', bridge: 'Bridge ownership', + auth: 'Bridge authorization', extension: 'Extension attachment', + no_browser_window: 'Browser window', content_script: 'Content script availability', tool_routing: 'Tool routing', healthy: 'Healthy', @@ -856,6 +870,7 @@ function withTopology(diagnostics: BridgeDiagnostics, topology: BridgeTopologySt activeHubInstanceId: topology.activeHubInstanceId, lastExtensionHeartbeatAt: topology.lastExtensionHeartbeatAt, lastDisconnectReason: topology.lastDisconnectReason, + extensionAttachment: topology.extensionAttachment, }; } @@ -900,11 +915,23 @@ function getGuidanceForLayer( : 'The local MCP bridge is disconnected.', nextAction: 'Keep one fsb-mcp-server instance running as the bridge owner, then rerun status --watch.', }; + case 'auth': + return { + why: snapshot.lastDisconnectReason === 'extension_origin_pin_mismatch' + ? 'A browser extension reached the bridge from an origin different from the paired extension ID.' + : 'The extension reached the bridge, but its reverse-channel authorization was refused or revoked.', + nextAction: 'Run `npx -y fsb-mcp-server@latest pair --reset`, then fully quit and reopen the browser.', + }; case 'extension': return { why: 'The local bridge is healthy, but no browser extension is attached to it.', nextAction: 'Open Chrome, Edge, or Brave with the FSB extension enabled and wait for it to attach.', }; + case 'no_browser_window': + return { + why: 'The FSB extension is attached, but its browser profile has no normal window open.', + nextAction: 'Open a normal browser window in the attached profile, then rerun doctor.', + }; case 'content_script': return { why: isContentScriptStale(snapshot.contentScript) @@ -931,22 +958,35 @@ export function classifyDoctorLayer(snapshot: BridgeDiagnostics): BridgeDiagnost return 'package'; } - if (snapshot.extensionConnected) { - const configProbeFailed = snapshot.probeNotes?.some((note) => note.scope === 'config') ?? false; - const configWasProbed = configProbeFailed || snapshot.extensionConfig !== undefined; - if (configWasProbed && (configProbeFailed || !hasConfiguredModel(snapshot.extensionConfig))) { - return 'config'; - } - } - if (snapshot.bridgeMode === 'disconnected' || (snapshot.bridgeMode === 'relay' && !snapshot.hubConnected)) { return 'bridge'; } + // An authorization refusal and a plain absence look identical from here + // otherwise, and the cures are opposite: one wants `pair --reset`, the other + // wants the browser opened. + if ( + !snapshot.extensionConnected + && snapshot.lastDisconnectReason !== null + && AUTH_DISCONNECT_REASONS.has(snapshot.lastDisconnectReason) + ) { + return 'auth'; + } + if (!snapshot.extensionConnected) { return 'extension'; } + if (snapshot.extensionAttachment?.normalWindowCount === 0) { + return 'no_browser_window'; + } + + const configProbeFailed = snapshot.probeNotes?.some((note) => note.scope === 'config') ?? false; + const configWasProbed = configProbeFailed || snapshot.extensionConfig !== undefined; + if (configWasProbed && (configProbeFailed || !hasConfiguredModel(snapshot.extensionConfig))) { + return 'config'; + } + const normalWebPage = Boolean(snapshot.activeTab.url) && snapshot.activeTab.restricted === false; if ( normalWebPage @@ -968,6 +1008,9 @@ export function applyDiagnosticClassification(snapshot: BridgeDiagnostics): Brid return { ...snapshot, diagnosticLayer, + diagnosticCode: diagnosticLayer === 'no_browser_window' ? 'NO_BROWSER_WINDOW' + : diagnosticLayer === 'auth' && snapshot.lastDisconnectReason === 'extension_origin_pin_mismatch' + ? 'ORIGIN_PIN_MISMATCH' : null, diagnosticWhy: guidance.why, nextAction: guidance.nextAction, }; @@ -1045,6 +1088,7 @@ export async function collectBridgeDiagnostics(options: { nativeHost, extensionConfig: undefined, bridgeClient: null, + extensionAttachment: bridge.topology.extensionAttachment, tabsSummary: undefined, probeNotes: versionMetadata.notes.length > 0 ? versionMetadata.notes : undefined, diagnosticLayer: 'healthy', @@ -1067,25 +1111,32 @@ export async function collectBridgeDiagnostics(options: { diagnostics.extensionConfig = normalizeConfig(payload?.config ?? payload); } - if (bridge.isConnected && options.includeTabs) { - const { payload, note } = await runBridgeProbe(bridge, 'tabs', 'mcp:get-tabs', 5_000); - if (note) notes.push(note); - const tabList = Array.isArray(payload?.tabs) - ? payload.tabs as Array> - : []; - const active = tabList.find((tab) => tab.active === true); - diagnostics.tabsSummary = { - totalTabs: tabList.length, - activeTabId: typeof active?.id === 'number' ? active.id : null, - }; - } - if (bridge.isConnected) { const { payload, note } = await runBridgeProbe(bridge, 'diagnostics', 'mcp:get-diagnostics', 5_000); if (note) notes.push(note); diagnostics.activeTab = normalizeActiveTab(payload?.activeTab); diagnostics.contentScript = normalizeContentScript(payload?.contentScript); diagnostics.bridgeClient = toRecord(payload?.bridgeClient); + const attachment = toRecord(payload?.attachment); + if (attachment && typeof attachment.extensionId === 'string' + && typeof attachment.extensionVersion === 'string' + && typeof attachment.installInstanceId === 'string' + && typeof attachment.normalWindowCount === 'number') { + diagnostics.extensionAttachment = { + extensionId: attachment.extensionId, + extensionVersion: attachment.extensionVersion, + installInstanceId: attachment.installInstanceId, + normalWindowCount: attachment.normalWindowCount, + connectedAt: typeof attachment.connectedAt === 'string' ? attachment.connectedAt : null, + }; + } + if (options.includeTabs) { + const summary = toRecord(payload?.tabsSummary); + diagnostics.tabsSummary = summary && typeof summary.totalTabs === 'number' + ? { totalTabs: summary.totalTabs, + activeTabId: typeof summary.activeTabId === 'number' ? summary.activeTabId : null } + : undefined; + } } } catch (err) { notes.push(createProbeNote('connect', err instanceof Error ? err.message : String(err))); diff --git a/mcp/src/errors.ts b/mcp/src/errors.ts index 7b8191fbe..e4da0d5b3 100644 --- a/mcp/src/errors.ts +++ b/mcp/src/errors.ts @@ -23,6 +23,8 @@ export const FSB_ERROR_MESSAGES: Record = { 'Content script communication failed. The content script may not be injected or has lost connection. Try navigating to the page again.', 'injection_failed': 'Content script injection failed. The extension could not inject its scripts into the current page.', + 'PAGE_UNRESPONSIVE': + 'The page did not answer within the bounded wait. Navigation and tab close remain available. Inspect the page before retrying an action.', 'queue_timeout': 'Tool call timed out waiting in queue. Another task is running and did not complete in time. Use stop_task to cancel the running task, or use read-only tools which bypass the queue.', 'invalid_client_label': @@ -42,6 +44,7 @@ const LAYER_LABELS = { bridge: 'Bridge ownership', extension: 'Extension attachment', contentScript: 'Content script availability', + pageResponsiveness: 'Page responsiveness', toolRouting: 'Tool routing', agentScope: 'Agent scope', tabOwnership: 'Tab ownership', @@ -75,6 +78,7 @@ const CODE_ONLY_ERROR_KEYS = new Set([ 'SCREENSHOT_TOO_LARGE', 'SCREENSHOT_DEBUGGER_BUSY', 'SCREENSHOT_CAPTURE_FAILED', + 'PAGE_UNRESPONSIVE', ]); type LayerLabel = typeof LAYER_LABELS[keyof typeof LAYER_LABELS]; @@ -251,6 +255,12 @@ function buildLayeredDetail( : 'The active tab does not have a ready content script.', nextAction: 'Refresh or navigate the tab, wait for page readiness, then retry.', }; + case 'PAGE_UNRESPONSIVE': + return { + detected: LAYER_LABELS.pageResponsiveness, + why: 'The current page did not respond before the page-read deadline.', + nextAction: 'Use navigate or close_tab to recover the tab. Inspect page state before repeating any mutation.', + }; case 'restricted_active_tab': return { detected: LAYER_LABELS.restrictedPage, @@ -426,6 +436,16 @@ export function mapFSBError( return { content: [{ type: 'text', text: JSON.stringify(fsbResult, null, 2) }] }; } + if (fsbResult?.mayHaveExecuted === true) { + return { + isError: true, + content: [{ + type: 'text', + text: `Detected: Action outcome unknown\nWhy: ${String(fsbResult.error || 'The action was dispatched but its result was not confirmed.')}\nNext action: Inspect the current page with read_page or get_dom_snapshot before deciding whether to retry.\n\n${JSON.stringify({ outcome: 'unknown', mayHaveExecuted: true })}`, + }], + }; + } + const errorMsg = String(fsbResult?.error ?? ''); const validRecoveryTools = getValidRecoveryTools(fsbResult); const errorKey = resolveErrorKey(fsbResult, errorMsg); diff --git a/mcp/src/http.ts b/mcp/src/http.ts index ef0998cc5..ee3ca86f0 100644 --- a/mcp/src/http.ts +++ b/mcp/src/http.ts @@ -16,6 +16,7 @@ import { import { FSB_MCP_VERSION } from './version.js'; const DELEGATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); type SessionContext = { server: McpServer; @@ -36,20 +37,12 @@ type RunningHttpServer = { close: () => Promise; }; -function setCorsHeaders(res: ServerResponse): void { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id, X-Fsb-Delegation-Id'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); - res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); -} - function sendJson( res: ServerResponse, statusCode: number, payload: Record, ): void { if (!res.headersSent) { - setCorsHeaders(res); res.statusCode = statusCode; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(payload, null, 2)); @@ -71,6 +64,9 @@ async function readJsonBody(req: IncomingMessage): Promise { } export async function startHttpServer(options: HttpServerOptions): Promise { + if (!LOOPBACK_HOSTS.has(options.host)) { + throw new Error('The FSB HTTP server only accepts a loopback --host (127.0.0.1, localhost, or ::1).'); + } const sessions = new Map(); let closed = false; let serveReady = false; @@ -87,7 +83,25 @@ export async function startHttpServer(options: HttpServerOptions): Promise => { - setCorsHeaders(res); + const address = server.address() as AddressInfo | null; + const port = address?.port; + const allowedHosts = new Set([ + `127.0.0.1:${port}`, + `localhost:${port}`, + `[::1]:${port}`, + ]); + const rawHosts: string[] = []; + let hasOrigin = false; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = req.rawHeaders[i]?.toLowerCase(); + if (name === 'host') rawHosts.push(req.rawHeaders[i + 1] ?? ''); + if (name === 'origin') hasOrigin = true; + } + if (rawHosts.length !== 1 || !allowedHosts.has(rawHosts[0].toLowerCase()) + || req.headers.host?.toLowerCase() !== rawHosts[0].toLowerCase() || hasOrigin) { + sendJson(res, 403, { error: 'Forbidden Host or Origin' }); + return; + } if (req.method === 'OPTIONS') { res.statusCode = 204; @@ -95,7 +109,8 @@ export async function startHttpServer(options: HttpServerOptions): Promise randomUUID(), onsessioninitialized: (newSessionId) => { sessions.set(newSessionId, { server: runtime.server, transport }); @@ -197,8 +215,9 @@ export async function startHttpServer(options: HttpServerOptions): Promise 0) { lines.push(`Note: ${diagnostics.probeNotes[0].message}`); } @@ -295,6 +313,7 @@ export function formatDoctor(diagnostics: DiagnosticsSnapshot): string { `Why: ${diagnostics.diagnosticWhy}`, `Next action: ${diagnostics.nextAction}`, ...formatFieldLines(buildCompactStatusFields(diagnostics)), + ...formatExtensionAttachment(diagnostics), ]; if (diagnostics.activeTab.url) { @@ -395,6 +414,13 @@ async function runStdioServer(): Promise { process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); + + // Without this, a host that exits by closing the pipe leaves the bridge + // running -- and holding the port -- forever. + shutdownWhenStdinEnds(process.stdin, () => { + console.error('[FSB MCP] stdin closed by the host; shutting down.'); + shutdown(); + }); } async function runHttpMode(flags: Record): Promise { @@ -405,7 +431,7 @@ async function runHttpMode(flags: Record): Promise { port, dependencies: { prepareBridgeAuth: () => { - rotateBridgeSessionSecret(); + if (!readBridgeAuthState()) rotateBridgeSessionSecret(); }, }, }); diff --git a/mcp/src/stdin-shutdown.ts b/mcp/src/stdin-shutdown.ts new file mode 100644 index 000000000..83692f6b3 --- /dev/null +++ b/mcp/src/stdin-shutdown.ts @@ -0,0 +1,41 @@ +/** + * The bridge holds a libuv handle open for the life of the process -- a + * listening http.Server in hub mode, an open client socket in relay mode -- so + * the event loop never drains on its own. A host that exits by closing the + * pipe, or that is killed without signalling its children, otherwise leaves a + * fully functional server running forever. Because port ownership is decided + * once by a bind race and never re-attempted, the bridge hub then becomes the + * oldest surviving orphan rather than a process anyone is still talking to. + */ + +export interface StdinLike { + readonly readableEnded?: boolean; + readonly destroyed?: boolean; + on(event: 'end' | 'close', listener: () => void): unknown; + resume(): unknown; +} + +/** + * Call `onEnd` exactly once when the host closes stdin. + * + * Returns true when stdin had already ended and `onEnd` ran synchronously -- + * stdin can reach EOF while the bridge is still connecting, and a listener + * attached after the fact would never fire. + */ +export function shutdownWhenStdinEnds(stdin: StdinLike, onEnd: () => void): boolean { + let fired = false; + const fire = (): void => { + if (fired) return; + fired = true; + onEnd(); + }; + + if (stdin.readableEnded || stdin.destroyed) { + fire(); + return true; + } + stdin.on('end', fire); + stdin.on('close', fire); + stdin.resume(); + return false; +} diff --git a/mcp/src/tools/autopilot.ts b/mcp/src/tools/autopilot.ts index 5b22d259f..a219cb5d0 100644 --- a/mcp/src/tools/autopilot.ts +++ b/mcp/src/tools/autopilot.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { isBridgeDisconnectError, type WebSocketBridge } from '../bridge.js'; +import { bridgeDisconnectReason, isBridgeDisconnectError, type WebSocketBridge } from '../bridge.js'; import type { TaskQueue } from '../queue.js'; import type { MCPResponse } from '../types.js'; import { AgentScope } from '../agent-scope.js'; @@ -193,6 +193,11 @@ export function registerAutopilotTools( const swEvictedResult = { success, sw_evicted: true, + // sw_evicted is the recovery arm and stays true for every + // disconnect, but an authorization revocation is not an eviction. + // Reporting the real cause is what keeps "restart Chrome" from + // being the only advice anyone ever gets. + disconnect_reason: bridgeDisconnectReason(sendErr) ?? 'bridge_disconnected', partial_state, last_heartbeat_at, }; diff --git a/mcp/src/tools/triggers.ts b/mcp/src/tools/triggers.ts index fb8b2353e..51605f584 100644 --- a/mcp/src/tools/triggers.ts +++ b/mcp/src/tools/triggers.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { isBridgeDisconnectError, type WebSocketBridge } from '../bridge.js'; +import { bridgeDisconnectReason, isBridgeDisconnectError, type WebSocketBridge } from '../bridge.js'; import type { TaskQueue } from '../queue.js'; import type { MCPMessageType, MCPResponse } from '../types.js'; import { AgentScope } from '../agent-scope.js'; @@ -147,6 +147,10 @@ function jsonText(value: Record): { content: Array<{ type: 'tex function disconnectedTriggerResult( triggerId: string, lookup: Record | null, + // sw_evicted stays true for every disconnect because the recovery is the + // same; this reports which disconnect it actually was, so an authorization + // failure stops being indistinguishable from a real eviction. + disconnectReason = 'bridge_disconnected', ): Record { // The extension's get-trigger-status (background.js fsbTriggerHandleToolStatus) // returns { success:true, status: } where the projected @@ -170,6 +174,7 @@ function disconnectedTriggerResult( success: true, sw_evicted: true, outcome: 'fired', + disconnect_reason: disconnectReason, trigger_id: triggerId, event: partialState?.last_event ?? partialState?.last_fire_event ?? null, status: partialState, @@ -181,6 +186,7 @@ function disconnectedTriggerResult( success: true, sw_evicted: true, outcome: 'timed_out', + disconnect_reason: disconnectReason, trigger_id: triggerId, status: partialState, }; @@ -190,6 +196,7 @@ function disconnectedTriggerResult( success: false, sw_evicted: true, outcome: 'detached', + disconnect_reason: disconnectReason, trigger_id: triggerId, partial_state: partialState, last_heartbeat_at: partialState?.last_heartbeat_at ?? null, @@ -279,7 +286,11 @@ export function registerTriggerTools( } catch (_lookupErr) { lookup = null; } - return jsonText(disconnectedTriggerResult(triggerId, lookup)); + return jsonText(disconnectedTriggerResult( + triggerId, + lookup, + bridgeDisconnectReason(sendErr) ?? 'bridge_disconnected', + )); } if (detached && result && result.success && result.outcome === undefined) { diff --git a/mcp/src/types.ts b/mcp/src/types.ts index 638f2320f..6d917f03d 100644 --- a/mcp/src/types.ts +++ b/mcp/src/types.ts @@ -80,6 +80,8 @@ export interface BridgeOptions { instanceId?: string; handshakeTimeoutMs?: number; relayHandshakeTimeoutMs?: number; + extensionPingIntervalMs?: number; + extensionHeartbeatTimeoutMs?: number; promotionJitterMs?: number; maxReconnectDelayMs?: number; allowedBrowserOrigins?: string[]; @@ -97,6 +99,15 @@ export interface BridgeTopologyState { activeHubInstanceId: string | null; lastExtensionHeartbeatAt: number | null; lastDisconnectReason: string | null; + extensionAttachment: ExtensionAttachmentState | null; +} + +export interface ExtensionAttachmentState { + extensionId: string; + extensionVersion: string; + installInstanceId: string; + normalWindowCount: number; + connectedAt: string | null; } // Relay protocol: MCP instance -> hub handshake @@ -115,6 +126,7 @@ export interface RelayWelcome { relayCount: number; lastExtensionHeartbeatAt: number | null; lastDisconnectReason: string | null; + extensionAttachment?: ExtensionAttachmentState | null; capabilities?: BridgeCapability[]; } @@ -125,6 +137,7 @@ export interface RelayState { relayCount: number; lastExtensionHeartbeatAt: number | null; lastDisconnectReason: string | null; + extensionAttachment?: ExtensionAttachmentState | null; capabilities?: BridgeCapability[]; } diff --git a/package.json b/package.json index b1b139c7c..3319a19be 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:screenshot:uat": "node --test tests/screenshot-uat.test.js", "test:stats": "node tests/stats-view-state.test.js && node tests/fsb-telemetry-service.test.js && node tests/stats-chart-overhaul.test.js && node tests/cumulative-commits-aggregator.test.js", "test:speech-to-text": "node tests/speech-to-text.test.js && node tests/voice-input-settings-ui.test.js", - "pretest": "node tests/version-sync.test.js && node --import tsx tests/dashboard-localization-fallback.test.mjs && node --test tests/lattice-replay-reclaim.test.js tests/lattice-replay-playback.test.js && node tests/replay-player-overlay.test.js && node tests/delegation-streaming-performance.test.js && node tests/mcp-session-recorder-journal.test.js && node --test tests/mcp-lattice-journal.test.js tests/mcp-session-export-port.test.js && npm run test:speech-to-text && npm run test:screenshot && node tests/agent-pooling.test.js && node tests/ownership-leakage-regression.test.js && npm run test:grok-build && npm run test:openai-request", + "pretest": "node tests/version-sync.test.js && node --import tsx tests/dashboard-localization-fallback.test.mjs && node --test tests/lattice-replay-reclaim.test.js tests/lattice-replay-playback.test.js && node tests/replay-player-overlay.test.js && node tests/delegation-streaming-performance.test.js && node tests/mcp-session-recorder-journal.test.js && node --test tests/mcp-lattice-journal.test.js tests/mcp-session-export-port.test.js && npm run test:speech-to-text && npm run test:screenshot && node --test tests/mcp-http-security.test.js tests/text-editing-contract.test.js tests/selector-fail-closed.test.js tests/hung-page-recovery.test.js tests/vault-list-state.test.js tests/mcp-attachment-diagnostics.test.js tests/twitter-guide-safety.test.js && node --test tests/mcp-wire-verb-routing.test.js tests/click-single-dispatch.test.js && node --test tests/browser-editing-actions.test.mjs && node tests/agent-pooling.test.js && node tests/ownership-leakage-regression.test.js && npm run test:grok-build && npm run test:openai-request", "test:mcp-journal": "node tests/mcp-session-recorder-journal.test.js && node --test tests/mcp-lattice-journal.test.js", "test:delegation-streaming": "node tests/delegation-streaming-performance.test.js", "pretest:lattice": "node --test tests/lattice-replay-reclaim.test.js", @@ -25,7 +25,7 @@ "version:check": "node scripts/sync-product-version.mjs --check", "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && node tests/agent-provider-forbidden-flags.test.js && node tests/phase60-full-tests-harness.test.js && node tests/phase64-full-tests-harness.test.js && node tests/phase65-full-tests-harness.test.js && node tests/delegation-routing.test.js && node tests/delegation-consent.test.js && npm --prefix mcp run build && node tests/delegation-event-store.test.js && node tests/delegation-controller.test.js && node tests/delegation-sidepanel-ui.test.js && node tests/delegation-phase-contract.test.js && node tests/mcp-native-host-packaging.test.js --section workflow-and-pack && node tests/mcp-native-host-protocol.test.js && node tests/mcp-native-host-daemon.test.js && node tests/mcp-native-host-registry-helper.test.js && node tests/mcp-native-host-install.test.js && node tests/native-host-background-wake.test.js && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-reverse-channel-contract.test.js && node tests/mcp-bridge-auth.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/agent-protocol-drift-diagnostics.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-agent-provider-contract.test.js && node tests/mcp-adapter-compatibility.test.js && node tests/mcp-agent-connection-test.test.js && node tests/mcp-claude-code-adapter.test.js && node tests/mcp-agent-drift-smoke.test.js && node tests/mcp-agent-stream-fixture.test.js && node tests/mcp-spawn-supervisor.test.js && node tests/mcp-agent-orphan-recovery.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/mcp-client-identity.test.js && node tests/mcp-client-inventory.test.js && node tests/mcp-agent-providers-storage.test.js && node tests/onboarding-agent-provider-clicks.test.js && node tests/mcp-client-merged-view.test.js && node tests/mcp-client-identity-integration.test.js && node tests/providers-panel-logic.test.js && node tests/providers-panel-ui.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/lmstudio-agent-request.test.js && node tests/lmstudio-startup-preflight.test.js && node tests/lmstudio-settings-persistence.test.js && node tests/onboarding-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/automation-logger-mcp-retention.test.js && node tests/mcp-session-settings-ui.test.js && node tests/knowledge-graph-dedup.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/telemetry-registry-bootstrap.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-parser-rate-limit.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-ip-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/telemetry-active-tracker-bounds.test.js && node tests/telemetry-uuid-budget-memory.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/stats-view-state.test.js && node tests/showcase-build-smoke.test.js && node tests/showcase-route-fallback.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-client-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/server-legacy-html-redirects.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/action-icon-behavior.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node --test tests/google-sheets-session.test.js tests/google-sheets-content-actions.test.js tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js tests/spreadsheet-record-redaction.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-session-replay.test.js && node tests/lattice-replay-background-contract.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/delegation-sidepanel-tab-follow.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/sidepanel-automation-runner.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && node tests/agent-provider-forbidden-flags.test.js && node tests/phase60-full-tests-harness.test.js && node tests/phase64-full-tests-harness.test.js && node tests/phase65-full-tests-harness.test.js && node tests/delegation-routing.test.js && node tests/delegation-consent.test.js && npm --prefix mcp run build && node tests/delegation-event-store.test.js && node tests/delegation-controller.test.js && node tests/delegation-sidepanel-ui.test.js && node tests/delegation-phase-contract.test.js && node tests/mcp-native-host-packaging.test.js --section workflow-and-pack && node tests/mcp-native-host-protocol.test.js && node tests/mcp-native-host-daemon.test.js && node tests/mcp-native-host-registry-helper.test.js && node tests/mcp-native-host-install.test.js && node tests/native-host-background-wake.test.js && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-reverse-channel-contract.test.js && node tests/mcp-bridge-auth.test.js && node tests/mcp-bridge-events.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/agent-protocol-drift-diagnostics.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-agent-provider-contract.test.js && node tests/mcp-adapter-compatibility.test.js && node tests/mcp-agent-connection-test.test.js && node tests/mcp-claude-code-adapter.test.js && node tests/mcp-agent-drift-smoke.test.js && node tests/mcp-agent-stream-fixture.test.js && node tests/mcp-spawn-supervisor.test.js && node tests/mcp-agent-orphan-recovery.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/mcp-client-identity.test.js && node tests/mcp-client-inventory.test.js && node tests/mcp-agent-providers-storage.test.js && node tests/onboarding-agent-provider-clicks.test.js && node tests/mcp-client-merged-view.test.js && node tests/mcp-client-identity-integration.test.js && node tests/providers-panel-logic.test.js && node tests/providers-panel-ui.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/lmstudio-agent-request.test.js && node tests/lmstudio-startup-preflight.test.js && node tests/lmstudio-settings-persistence.test.js && node tests/onboarding-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/automation-logger-mcp-retention.test.js && node tests/mcp-session-settings-ui.test.js && node tests/knowledge-graph-dedup.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/telemetry-registry-bootstrap.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-parser-rate-limit.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-ip-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/telemetry-active-tracker-bounds.test.js && node tests/telemetry-uuid-budget-memory.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/stats-view-state.test.js && node tests/showcase-build-smoke.test.js && node tests/showcase-route-fallback.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-client-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/server-legacy-html-redirects.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/action-icon-behavior.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node --test tests/google-sheets-session.test.js tests/google-sheets-content-actions.test.js tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js tests/spreadsheet-record-redaction.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-session-replay.test.js && node tests/lattice-replay-background-contract.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/delegation-sidepanel-tab-follow.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/sidepanel-automation-runner.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:openai-request": "node tests/openai-agent-request.test.js", "test:grok-build": "npm --prefix mcp run build && node tests/mcp-grok-build-adapter.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-session-replay.test.js && node --test tests/lattice-replay-playback.test.js && node tests/lattice-replay-background-contract.test.js && node tests/lattice-step-emitter-smoke.test.js", diff --git a/showcase/angular/public/llms-full.txt b/showcase/angular/public/llms-full.txt index cd9172e4b..793d7580f 100644 --- a/showcase/angular/public/llms-full.txt +++ b/showcase/angular/public/llms-full.txt @@ -1,4 +1,4 @@ - + # FSB (Full Self-Browsing) ## 1. Project Description diff --git a/showcase/angular/public/llms.txt b/showcase/angular/public/llms.txt index 165fa4fda..475cc0a0a 100644 --- a/showcase/angular/public/llms.txt +++ b/showcase/angular/public/llms.txt @@ -1,4 +1,4 @@ - + # FSB (Full Self-Browsing) FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task; FSB plans the clicks, types, and navigation to complete it. Multi-model AI (xAI, OpenAI, Anthropic, Gemini, OpenRouter, LM Studio, local), 56 browser tools, a 68-tool MCP surface, 142+ site guides, local-first execution, and BYO API keys. Current release: FSB v0.9.91 (extension) with fsb-mcp-server 0.11.0 (npm). diff --git a/showcase/angular/public/sitemap.xml b/showcase/angular/public/sitemap.xml index ea8493f5c..22aa31823 100644 --- a/showcase/angular/public/sitemap.xml +++ b/showcase/angular/public/sitemap.xml @@ -2,7 +2,7 @@ https://full-selfbrowsing.com - 2026-09-01 + 2026-09-29 weekly 1.0 @@ -16,7 +16,7 @@ https://full-selfbrowsing.com/es - 2026-09-01 + 2026-09-29 weekly 1.0 @@ -30,7 +30,7 @@ https://full-selfbrowsing.com/de - 2026-09-01 + 2026-09-29 weekly 1.0 @@ -44,7 +44,7 @@ https://full-selfbrowsing.com/ja - 2026-09-01 + 2026-09-29 weekly 1.0 @@ -58,7 +58,7 @@ https://full-selfbrowsing.com/ko - 2026-09-01 + 2026-09-29 weekly 1.0 @@ -72,7 +72,7 @@ https://full-selfbrowsing.com/zh-CN - 2026-09-01 + 2026-09-29 weekly 1.0 @@ -86,7 +86,7 @@ https://full-selfbrowsing.com/zh-TW - 2026-09-01 + 2026-09-29 weekly 1.0 @@ -100,7 +100,7 @@ https://full-selfbrowsing.com/about - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -114,7 +114,7 @@ https://full-selfbrowsing.com/es/about - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -128,7 +128,7 @@ https://full-selfbrowsing.com/de/about - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -142,7 +142,7 @@ https://full-selfbrowsing.com/ja/about - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -156,7 +156,7 @@ https://full-selfbrowsing.com/ko/about - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -170,7 +170,7 @@ https://full-selfbrowsing.com/zh-CN/about - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -184,7 +184,7 @@ https://full-selfbrowsing.com/zh-TW/about - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -198,7 +198,7 @@ https://full-selfbrowsing.com/agents - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -212,7 +212,7 @@ https://full-selfbrowsing.com/es/agents - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -226,7 +226,7 @@ https://full-selfbrowsing.com/de/agents - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -240,7 +240,7 @@ https://full-selfbrowsing.com/ja/agents - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -254,7 +254,7 @@ https://full-selfbrowsing.com/ko/agents - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -268,7 +268,7 @@ https://full-selfbrowsing.com/zh-CN/agents - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -282,7 +282,7 @@ https://full-selfbrowsing.com/zh-TW/agents - 2026-09-01 + 2026-09-29 weekly 0.9 @@ -296,7 +296,7 @@ https://full-selfbrowsing.com/support - 2026-09-01 + 2026-09-29 monthly 0.7 @@ -310,7 +310,7 @@ https://full-selfbrowsing.com/es/support - 2026-09-01 + 2026-09-29 monthly 0.7 @@ -324,7 +324,7 @@ https://full-selfbrowsing.com/de/support - 2026-09-01 + 2026-09-29 monthly 0.7 @@ -338,7 +338,7 @@ https://full-selfbrowsing.com/ja/support - 2026-09-01 + 2026-09-29 monthly 0.7 @@ -352,7 +352,7 @@ https://full-selfbrowsing.com/ko/support - 2026-09-01 + 2026-09-29 monthly 0.7 @@ -366,7 +366,7 @@ https://full-selfbrowsing.com/zh-CN/support - 2026-09-01 + 2026-09-29 monthly 0.7 @@ -380,7 +380,7 @@ https://full-selfbrowsing.com/zh-TW/support - 2026-09-01 + 2026-09-29 monthly 0.7 @@ -394,7 +394,7 @@ https://full-selfbrowsing.com/privacy - 2026-09-01 + 2026-09-29 yearly 0.5 @@ -408,7 +408,7 @@ https://full-selfbrowsing.com/es/privacy - 2026-09-01 + 2026-09-29 yearly 0.5 @@ -422,7 +422,7 @@ https://full-selfbrowsing.com/de/privacy - 2026-09-01 + 2026-09-29 yearly 0.5 @@ -436,7 +436,7 @@ https://full-selfbrowsing.com/ja/privacy - 2026-09-01 + 2026-09-29 yearly 0.5 @@ -450,7 +450,7 @@ https://full-selfbrowsing.com/ko/privacy - 2026-09-01 + 2026-09-29 yearly 0.5 @@ -464,7 +464,7 @@ https://full-selfbrowsing.com/zh-CN/privacy - 2026-09-01 + 2026-09-29 yearly 0.5 @@ -478,7 +478,7 @@ https://full-selfbrowsing.com/zh-TW/privacy - 2026-09-01 + 2026-09-29 yearly 0.5 @@ -492,7 +492,7 @@ https://full-selfbrowsing.com/release-notes - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -506,7 +506,7 @@ https://full-selfbrowsing.com/es/release-notes - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -520,7 +520,7 @@ https://full-selfbrowsing.com/de/release-notes - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -534,7 +534,7 @@ https://full-selfbrowsing.com/ja/release-notes - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -548,7 +548,7 @@ https://full-selfbrowsing.com/ko/release-notes - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -562,7 +562,7 @@ https://full-selfbrowsing.com/zh-CN/release-notes - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -576,7 +576,7 @@ https://full-selfbrowsing.com/zh-TW/release-notes - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -590,7 +590,7 @@ https://full-selfbrowsing.com/lattice - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -604,7 +604,7 @@ https://full-selfbrowsing.com/es/lattice - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -618,7 +618,7 @@ https://full-selfbrowsing.com/de/lattice - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -632,7 +632,7 @@ https://full-selfbrowsing.com/ja/lattice - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -646,7 +646,7 @@ https://full-selfbrowsing.com/ko/lattice - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -660,7 +660,7 @@ https://full-selfbrowsing.com/zh-CN/lattice - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -674,7 +674,7 @@ https://full-selfbrowsing.com/zh-TW/lattice - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -688,7 +688,7 @@ https://full-selfbrowsing.com/concierge - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -702,7 +702,7 @@ https://full-selfbrowsing.com/es/concierge - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -716,7 +716,7 @@ https://full-selfbrowsing.com/de/concierge - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -730,7 +730,7 @@ https://full-selfbrowsing.com/ja/concierge - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -744,7 +744,7 @@ https://full-selfbrowsing.com/ko/concierge - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -758,7 +758,7 @@ https://full-selfbrowsing.com/zh-CN/concierge - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -772,7 +772,7 @@ https://full-selfbrowsing.com/zh-TW/concierge - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -786,7 +786,7 @@ https://full-selfbrowsing.com/phantom-stream - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -800,7 +800,7 @@ https://full-selfbrowsing.com/es/phantom-stream - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -814,7 +814,7 @@ https://full-selfbrowsing.com/de/phantom-stream - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -828,7 +828,7 @@ https://full-selfbrowsing.com/ja/phantom-stream - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -842,7 +842,7 @@ https://full-selfbrowsing.com/ko/phantom-stream - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -856,7 +856,7 @@ https://full-selfbrowsing.com/zh-CN/phantom-stream - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -870,7 +870,7 @@ https://full-selfbrowsing.com/zh-TW/phantom-stream - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -884,7 +884,7 @@ https://full-selfbrowsing.com/prometheus - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -898,7 +898,7 @@ https://full-selfbrowsing.com/es/prometheus - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -912,7 +912,7 @@ https://full-selfbrowsing.com/de/prometheus - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -926,7 +926,7 @@ https://full-selfbrowsing.com/ja/prometheus - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -940,7 +940,7 @@ https://full-selfbrowsing.com/ko/prometheus - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -954,7 +954,7 @@ https://full-selfbrowsing.com/zh-CN/prometheus - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -968,7 +968,7 @@ https://full-selfbrowsing.com/zh-TW/prometheus - 2026-09-01 + 2026-09-29 weekly 0.8 @@ -982,7 +982,7 @@ https://full-selfbrowsing.com/sitemaps - 2026-09-01 + 2026-09-29 monthly 0.6 @@ -996,7 +996,7 @@ https://full-selfbrowsing.com/es/sitemaps - 2026-09-01 + 2026-09-29 monthly 0.6 @@ -1010,7 +1010,7 @@ https://full-selfbrowsing.com/de/sitemaps - 2026-09-01 + 2026-09-29 monthly 0.6 @@ -1024,7 +1024,7 @@ https://full-selfbrowsing.com/ja/sitemaps - 2026-09-01 + 2026-09-29 monthly 0.6 @@ -1038,7 +1038,7 @@ https://full-selfbrowsing.com/ko/sitemaps - 2026-09-01 + 2026-09-29 monthly 0.6 @@ -1052,7 +1052,7 @@ https://full-selfbrowsing.com/zh-CN/sitemaps - 2026-09-01 + 2026-09-29 monthly 0.6 @@ -1066,7 +1066,7 @@ https://full-selfbrowsing.com/zh-TW/sitemaps - 2026-09-01 + 2026-09-29 monthly 0.6 diff --git a/skills/fsb/USAGE.md b/skills/fsb/USAGE.md index 0824c4e74..3e61d3f57 100644 --- a/skills/fsb/USAGE.md +++ b/skills/fsb/USAGE.md @@ -28,6 +28,8 @@ The goal of this page: get a new user from a clean machine to a green doctor in https://github.com/fullselfbrowsing/FSB/releases ``` + For an unpacked install, record the extension ID shown on `chrome://extensions`; changing the install path or browser profile can change that ID. Branded Chrome 137 no longer accepts `--load-extension`; use the **Load unpacked** button. [Chrome for Testing and Chromium still support the flag](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ). + This skill prints the URL for the user to click. It does NOT auto-launch the browser to that URL. 2. **Install the FSB MCP server config.** @@ -141,3 +143,5 @@ Each `[FAIL]` from `node scripts/doctor.mjs` maps to one of six layers. Find you | config | The MCP host config does not include the FSB stdio block. | Re-run `npx -y fsb-mcp-server@latest install --` for your MCP host (replace `` with `claude-desktop`, `cursor`, etc.). Or paste the block printed by `node scripts/print-stdio.mjs` into your host config. | Re-run `node scripts/doctor.mjs` after each fix. If a layer keeps flipping or you see `[WARN]`, capture the raw output and file an issue at `https://github.com/fullselfbrowsing/FSB/issues`. + +For MCP attachment problems, `npx -y fsb-mcp-server@latest doctor` shows the attached extension ID, extension version, install instance ID, connection time, and normal-window count. `NO_BROWSER_WINDOW` means to open a normal window in that profile. `ORIGIN_PIN_MISMATCH` means to run `npx -y fsb-mcp-server@latest pair --reset` and pair again. Only one browser profile holds the bridge attachment at a time; the MCP server and extension are versioned independently. Local HTTP `serve` accepts loopback clients only. After an HTTP restart, reconnect the client to the printed endpoint. If a page read returns `PAGE_UNRESPONSIVE`, recover with `navigate` or `close_tab`; inspect state before repeating an uncertain mutation. diff --git a/tests/browser-editing-actions.test.mjs b/tests/browser-editing-actions.test.mjs new file mode 100644 index 000000000..7ac5acc42 --- /dev/null +++ b/tests/browser-editing-actions.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import test from 'node:test'; + +// Node 20 has no global WebSocket; reuse the mcp package's ws client there. +const WebSocketClient = globalThis.WebSocket + ?? createRequire(import.meta.url)('../mcp/node_modules/ws'); + +const chrome = [process.env.FSB_CHROME_BIN, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', '/usr/bin/chromium'].find(value => value && existsSync(value)); + +async function evaluate(ws, expression, requestId) { + return new Promise((resolve, reject) => { + const listener = event => { + const message = JSON.parse(event.data); + if (message.id !== requestId) return; + ws.removeEventListener('message', listener); + if (message.error) reject(new Error(message.error.message)); + else resolve(message.result?.result?.value); + }; + ws.addEventListener('message', listener); + ws.send(JSON.stringify({ id: requestId, method: 'Runtime.evaluate', + params: { expression, returnByValue: true } })); + }); +} + +test('Chrome fixture inserts multiline Draft text once and dispatches one click', + { skip: !chrome }, async () => { + const profile = mkdtempSync(join(tmpdir(), 'fsb-browser-actions-')); + const fixture = new URL('./fixtures/browser-editing-actions.html', import.meta.url).href; + const child = spawn(chrome, [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--remote-debugging-port=0', `--user-data-dir=${profile}`, fixture + ], { stdio: 'ignore' }); + let ws; + try { + let port; + for (let i = 0; i < 100; i++) { + const portFile = join(profile, 'DevToolsActivePort'); + if (existsSync(portFile)) { + port = Number(readFileSync(portFile, 'utf8').split('\n')[0]); + break; + } + await sleep(100); + } + assert.ok(port, 'Chrome debugging port opened'); + let target; + for (let i = 0; i < 100; i++) { + const pages = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); + target = pages.find(page => page.type === 'page' && page.url.includes('browser-editing-actions')); + if (target) break; + await sleep(100); + } + assert.ok(target, 'fixture tab opened'); + ws = new WebSocketClient(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }); + ws.addEventListener('error', reject, { once: true }); + }); + let raw = 'pending'; + for (let i = 0; i < 150; i++) { + raw = await evaluate(ws, 'document.querySelector("#result")?.textContent', i + 1); + if (raw && raw !== 'pending') break; + await sleep(200); + } + assert.notEqual(raw, 'pending', 'fixture completed within 30 seconds'); + const result = JSON.parse(raw); + assert.equal(result.a.success, true); + assert.equal(result.a.final_text, 'first\nsecond'); + assert.equal(result.b.success, true); + assert.equal(result.b.final_text, 'first\nsecond\nthird'); + assert.equal(result.second, 'first\nsecond\nthird'); + assert.equal(result.c.success, false); + assert.match(result.c.error, /single editable/); + assert.equal(result.clickEvents, 1); + assert.equal(result.d.outcome, 'unknown'); + assert.equal(result.d.mayHaveExecuted, true); + } finally { + ws?.close(); + // Chrome keeps writing its profile until it exits; removing it earlier races. + const exited = child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : new Promise(resolve => child.once('exit', resolve)); + child.kill('SIGTERM'); + await Promise.race([exited, sleep(5000)]); + rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); diff --git a/tests/capability-autopilot-parity.test.js b/tests/capability-autopilot-parity.test.js index bcf38c4e9..4fc91b077 100644 --- a/tests/capability-autopilot-parity.test.js +++ b/tests/capability-autopilot-parity.test.js @@ -40,7 +40,7 @@ const REPO_ROOT = path.resolve(__dirname, '..'); // tests/capability-mcp-surface.test.js:58-59 / tool-definitions-parity.test.js:52. // The two out-of-registry capability tools must NOT have moved this. const EXPECTED_NON_TRIGGER_REGISTRY_HASH = - 'b9c30a5a61fbcdae60b851aebfea90d0961cb95d95c3c2adbf8357014bbbd7b9'; + '5b9731c2282461ff85a4e5ac5bab18ca6d4eae55f8695cdcaf0fb8d457322c29'; // The four trigger tools sit IN TOOL_REGISTRY but are excluded from the frozen // non-trigger baseline (mirrors capability-mcp-surface.test.js:63). @@ -105,7 +105,7 @@ async function run() { const actualHash = registryHash(nonTriggerTools); check( actualHash === EXPECTED_NON_TRIGGER_REGISTRY_HASH, - 'EXPECTED_NON_TRIGGER_REGISTRY_HASH is unchanged -- the two capability tools are out-of-registry (INV-01)' + 'non-trigger registry hash matches the approved text-editing schema baseline' ); if (actualHash !== EXPECTED_NON_TRIGGER_REGISTRY_HASH) { console.error(' DIAG: expected ' + EXPECTED_NON_TRIGGER_REGISTRY_HASH); diff --git a/tests/capability-mcp-surface.test.js b/tests/capability-mcp-surface.test.js index 8a18c46bf..5661be9c3 100644 --- a/tests/capability-mcp-surface.test.js +++ b/tests/capability-mcp-surface.test.js @@ -56,7 +56,7 @@ const REPO_ROOT = path.resolve(__dirname, '..'); // The INV-01 lock value -- reused verbatim from tests/tool-definitions-parity.test.js:52. // The two out-of-registry capability tools must NOT have moved this. const EXPECTED_NON_TRIGGER_REGISTRY_HASH = - 'b9c30a5a61fbcdae60b851aebfea90d0961cb95d95c3c2adbf8357014bbbd7b9'; + '5b9731c2282461ff85a4e5ac5bab18ca6d4eae55f8695cdcaf0fb8d457322c29'; // The four trigger tools sit IN TOOL_REGISTRY but are excluded from the frozen // non-trigger baseline (mirrors tool-definitions-parity.test.js:35). @@ -154,7 +154,7 @@ async function run() { const actualHash = registryHash(nonTriggerTools); check( actualHash === EXPECTED_NON_TRIGGER_REGISTRY_HASH, - 'EXPECTED_NON_TRIGGER_REGISTRY_HASH is unchanged -- the two new tools are out-of-registry (INV-01)' + 'non-trigger registry hash matches the approved text-editing schema baseline' ); if (actualHash !== EXPECTED_NON_TRIGGER_REGISTRY_HASH) { console.error(' DIAG: expected ' + EXPECTED_NON_TRIGGER_REGISTRY_HASH); diff --git a/tests/click-single-dispatch.test.js b/tests/click-single-dispatch.test.js new file mode 100644 index 000000000..2a52700da --- /dev/null +++ b/tests/click-single-dispatch.test.js @@ -0,0 +1,98 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const actions = fs.readFileSync(path.join(__dirname, '../extension/content/actions.js'), 'utf8'); +const coordinateStart = actions.indexOf('async function clickAtCoordinates('); +const coordinateEnd = actions.indexOf('// =============================================================================\n// END COORDINATE FALLBACK', coordinateStart); +assert(coordinateStart >= 0 && coordinateEnd > coordinateStart); +const coordinateSource = actions.slice(coordinateStart, coordinateEnd); + +function fixtureElement(kind) { + const calls = { click: 0, nativeClick: 0, submit: 0, toggle: 0 }; + const element = { + tagName: kind === 'submit' ? 'BUTTON' : 'INPUT', + id: 'target', + checked: false, + dispatchEvent(event) { + if (event.type === 'click') { + calls.click++; + if (kind === 'submit') calls.submit++; + else { this.checked = !this.checked; calls.toggle++; } + } + }, + click() { calls.nativeClick++; }, + }; + return { element, calls }; +} + +for (const kind of ['submit', 'checkbox']) { + test(`coordinate fallback dispatches one ${kind} activation`, async () => { + const { element, calls } = fixtureElement(kind); + let cdpCalls = 0; + const context = { + MouseEvent: class MouseEvent { constructor(type) { this.type = type; } }, + window: { scrollX: 0, scrollY: 0, screenX: 0, screenY: 0 }, + ensureCoordinatesVisible: async () => ({ scrolled: false }), + validateCoordinates: () => ({ valid: true, element }), + waitForStability: async () => {}, + logger: { warn() {}, log() {} }, + FSB: { sessionId: 'fixture', getClassName: () => '' }, + chrome: { runtime: { sendMessage: async () => { cdpCalls++; } } }, + }; + const clickAtCoordinates = vm.runInNewContext(`${coordinateSource}\nclickAtCoordinates`, context); + const result = await clickAtCoordinates({ x: 10, y: 10, width: 20, height: 20 }); + assert.equal(calls.click, 1); + assert.equal(calls.nativeClick, 0); + assert.equal(cdpCalls, 0); + assert.equal(calls[kind === 'submit' ? 'submit' : 'toggle'], 1); + assert.equal(result.outcome, 'unknown'); + assert.equal(result.mayHaveExecuted, true); + }); +} + +test('selector click does not dispatch a second action after its click event', () => { + const start = actions.indexOf('// Dispatch full mouse event sequence for proper JS handler triggering'); + const end = actions.indexOf('// VERIFY-04: Wait for page stability', start); + const dispatch = actions.slice(start, end); + assert.equal((dispatch.match(/new MouseEvent\('click'/g) || []).length, 1); + assert.doesNotMatch(dispatch, /element\.click\(/); + const uncertainStart = actions.indexOf('if (!hadEffect)', end); + const uncertainEnd = actions.indexOf('// Record successful action', uncertainStart); + assert.doesNotMatch(actions.slice(uncertainStart, uncertainEnd), /form\.submit\(|cdpMouseClick|window\.location\.href/); +}); + +test('site search sends Enter once and leaves an unchanged page uncertain', async () => { + const start = actions.indexOf(' siteSearch: async (params) => {'); + const end = actions.indexOf(' // Wait for element to appear', start); + assert(start >= 0 && end > start); + const calls = { enter: 0, submit: 0, google: 0 }; + const searchInput = { + value: '', + focus() {}, + click() {}, + dispatchEvent(event) { if (event.type === 'keydown' && event.key === 'Enter') calls.enter++; }, + closest() { return { submit() { calls.submit++; } }; }, + }; + const context = { + FSB: { smartEnsureReady: async () => ({ ready: true }) }, + detectSiteSearchInput: () => ({ element: searchInput, tier: 1, selector: '#search' }), + Event: class { constructor(type) { this.type = type; } }, + KeyboardEvent: class { constructor(type, options) { this.type = type; this.key = options.key; } }, + setTimeout: (callback) => { callback(); return 0; }, + waitForPageStability: async () => {}, + window: { location: { href: 'https://example.test/' } }, + }; + const tools = vm.runInNewContext(`({${actions.slice(start, end)}})`, context); + tools.searchGoogle = () => { calls.google++; return { success: true }; }; + const result = await tools.siteSearch({ query: 'hello' }); + assert.equal(calls.enter, 1); + assert.equal(calls.submit, 0); + assert.equal(calls.google, 0); + assert.equal(result.outcome, 'unknown'); + assert.equal(result.mayHaveExecuted, true); +}); diff --git a/tests/delegation-phase-contract.test.js b/tests/delegation-phase-contract.test.js index cfde32f81..29ce7fbac 100644 --- a/tests/delegation-phase-contract.test.js +++ b/tests/delegation-phase-contract.test.js @@ -127,6 +127,7 @@ const PHASE65_NEW_ROOT_COMMANDS = Object.freeze([ ]); const REFINEMENT_ROOT_TEST_COMMANDS = Object.freeze([ + 'node tests/mcp-bridge-events.test.js', 'node tests/mcp-agent-connection-test.test.js', 'node tests/lmstudio-agent-request.test.js', 'node tests/lmstudio-startup-preflight.test.js', diff --git a/tests/fixtures/browser-editing-actions.html b/tests/fixtures/browser-editing-actions.html new file mode 100644 index 000000000..0e6b08dbd --- /dev/null +++ b/tests/fixtures/browser-editing-actions.html @@ -0,0 +1,11 @@ + +
old
line
+
+
pending
+ + + diff --git a/tests/hung-page-recovery.test.js b/tests/hung-page-recovery.test.js new file mode 100644 index 000000000..2ae16aebf --- /dev/null +++ b/tests/hung-page-recovery.test.js @@ -0,0 +1,331 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const bridge = fs.readFileSync(path.join(__dirname, '../extension/ws/mcp-bridge-client.js'), 'utf8'); +const dispatcher = fs.readFileSync(path.join(__dirname, '../extension/ws/mcp-tool-dispatcher.js'), 'utf8'); +const background = fs.readFileSync(path.join(__dirname, '../extension/background.js'), 'utf8'); + +test('a hung page read returns a typed bounded error', async () => { + const start = bridge.indexOf(' async _sendToContentScript(tabId, message) {'); + const end = bridge.indexOf('\n async _handleGetTabs(', start); + const method = bridge.slice(start, end).replace('12000);', '20);'); + const context = { + sendMessageWithRetry: () => new Promise(() => {}), + setTimeout, clearTimeout + }; + const send = vm.runInNewContext(`({${method}})._sendToContentScript`, context); + const result = await send(9, { action: 'readPage' }); + assert.equal(result.success, false); + assert.equal(result.errorCode, 'PAGE_UNRESPONSIVE'); +}); + +test('a hung mutation reports uncertain execution', async () => { + const start = bridge.indexOf(' async _sendToContentScript(tabId, message) {'); + const end = bridge.indexOf('\n async _handleGetTabs(', start); + const method = bridge.slice(start, end).replace('12000);', '20);'); + const context = { sendMessageWithRetry: () => new Promise(() => {}), setTimeout, clearTimeout }; + const send = vm.runInNewContext(`({${method}})._sendToContentScript`, context); + const result = await send(9, { action: 'executeAction', tool: 'click' }); + assert.equal(result.outcome, 'unknown'); + assert.equal(result.mayHaveExecuted, true); +}); + +test('navigation and close skip page-side change reports', () => { + const wrapper = dispatcher.slice(dispatcher.indexOf('async function wrapWithChangeReport('), + dispatcher.indexOf('\n// ', dispatcher.indexOf('async function wrapWithChangeReport(') + 20)); + assert.match(wrapper, /'navigate', 'close_tab'/); + assert.match(wrapper, /return execute\(\)/); +}); + +test('an expired mutation is never sent after delayed page recovery', async () => { + const start = background.indexOf('async function sendMessageWithRetry('); + const end = background.indexOf('\n// Alternative action strategies', start); + let sent = 0; + const context = { + Date, + chrome: { tabs: { + get: async () => ({ url: 'https://example.com' }), + sendMessage: async () => { sent++; return { success: true }; } + } }, + checkContentScriptHealth: async () => true + }; + const send = vm.runInNewContext(`${background.slice(start, end)}\nsendMessageWithRetry`, context); + const result = await send(7, { action: 'executeAction', tool: 'click', + _fsbDeadlineAt: Date.now() - 1 }); + assert.equal(result.errorCode, 'PAGE_UNRESPONSIVE'); + assert.equal(result.mayHaveExecuted, false); + assert.equal(sent, 0); +}); + +test('CDP lease watchdog releases a hung holder for the next tab operation', async () => { + const source = fs.readFileSync(path.join(__dirname, '../extension/utils/cdp-lease.js'), 'utf8'); + const timers = []; + const context = { + Map, Promise, Number, Error, TypeError, + setTimeout(callback, delay) { + const timer = { callback, delay, unref() {} }; + timers.push(timer); + return timer; + }, + clearTimeout(timer) { timer.cancelled = true; }, + module: { exports: {} } + }; + context.globalThis = context; + vm.runInNewContext(source, context); + const lease = await context.module.exports.acquire(11); + const next = context.module.exports.acquire(11); + const watchdog = timers.find(timer => timer.delay === 20000); + assert.ok(watchdog); + watchdog.callback(); + const secondLease = await next; + assert.equal(secondLease.tabId, 11); + secondLease.release(); + lease.release(); +}); + +function leaseContext() { + const source = fs.readFileSync(path.join(__dirname, '../extension/utils/cdp-lease.js'), 'utf8'); + const timers = []; + const context = { + Map, Promise, Number, Error, TypeError, + setTimeout(callback, delay) { + const timer = { callback, delay, unref() {} }; + timers.push(timer); + return timer; + }, + clearTimeout(timer) { timer.cancelled = true; }, + module: { exports: {} } + }; + context.globalThis = context; + vm.runInNewContext(source, context); + return { lease: context.module.exports, timers }; +} + +test('a holder that asks for a longer lease is not preempted at the default watchdog', async () => { + const { lease, timers } = leaseContext(); + const capture = await lease.acquire(11, { holdMs: 40000 }); + assert.deepEqual(timers.map(timer => timer.delay), [40000]); + const typing = lease.acquire(11, { holdMs: 65000 }); + capture.release(); + const handedOff = await typing; + assert.equal(timers.filter(timer => !timer.cancelled && timer.delay !== 10000).at(-1).delay, 65000); + handedOff.release(); +}); + +test('long typing and hold verbs size their lease past the work they do', () => { + const pick = (name, endMarker) => { + const start = background.indexOf(`function ${name}(`); + return background.slice(start, background.indexOf(endMarker, start)); + }; + const typingHold = vm.runInNewContext( + `${pick('keyboardActionLeaseHoldMs', '\n}\n')}\n}\nkeyboardActionLeaseHoldMs`, { Number, Math, Array }); + const verbHold = vm.runInNewContext( + `${pick('cdpToolLeaseHoldMs', '\n}\n')}\n}\ncdpToolLeaseHoldMs`, { Number }); + // typeWithKeys spends ~45ms a character at delay 30; 400 characters already pass 20s. + assert.ok(typingHold('typeText', 'x'.repeat(400), undefined, 30) > 400 * 45 + 20000); + assert.ok(typingHold('pressKeySequence', undefined, new Array(300).fill('a'), 50) > 300 * 60 + 20000); + assert.equal(typingHold('pressKey', undefined, undefined, 50), 20150); + assert.ok(verbHold({ holdMs: 30000 }) > 30000 + 20000 - 1); + assert.ok(verbHold({ steps: 500, maxDelayMs: 40 }) > 500 * 40 + 20000); + assert.equal(verbHold(undefined), 20000); +}); + +function harvestPage() { + const startAt = dispatcher.indexOf('function _fsbHarvestStartInPage('); + const stopAt = dispatcher.indexOf('function _fsbHarvestStopInPage('); + const source = dispatcher.slice(startAt, dispatcher.indexOf('\n// Page-context harvest stop', startAt)) + + dispatcher.slice(stopAt, dispatcher.indexOf('\n// Wait for DOM-stable', stopAt)); + const observers = []; + const timers = []; + class FakeMutationObserver { + constructor(callback) { this.callback = callback; this.connected = false; observers.push(this); } + observe() { this.connected = true; } + disconnect() { this.connected = false; } + } + const root = { tagName: 'HTML', className: '', textContent: '', parentElement: null, getAttribute: () => null }; + const window = { location: { href: 'https://example.com/' } }; + const context = { + window, + document: { + documentElement: root, title: 'Example', activeElement: null, + querySelector: () => null, querySelectorAll: () => [] + }, + MutationObserver: FakeMutationObserver, + Date, String, + setTimeout(callback, delay) { const timer = { callback, delay }; timers.push(timer); return timer; }, + clearTimeout(timer) { if (timer) timer.cleared = true; } + }; + const page = vm.runInNewContext(`${source}\n({ start: _fsbHarvestStartInPage, stop: _fsbHarvestStopInPage })`, context); + return { page, window, observers, timers, root }; +} + +test('a change-report start that lands after the service worker gave up installs no observer', () => { + const { page, window, observers } = harvestPage(); + const result = page.start(null, 'late', Date.now() - 1); + assert.equal(result.ok, false); + assert.equal(observers.length, 0); + assert.equal(window.__fsbChangeReportHandles, undefined); +}); + +test('overlapping change-report harvests only stop their own observer', () => { + const { page, window, observers } = harvestPage(); + assert.equal(page.start(null, 'a', Date.now() + 1000).ok, true); + assert.equal(page.start(null, 'b', Date.now() + 1000).ok, true); + page.stop('a'); + assert.equal(observers[0].connected, false); + assert.equal(observers[1].connected, true); + page.stop('b'); + assert.equal(observers[1].connected, false); + assert.equal(Object.keys(window.__fsbChangeReportHandles).length, 0); +}); + +test('an unstopped change-report observer keeps bounded records and expires', () => { + const { page, window, observers, timers, root } = harvestPage(); + page.start(null, 'orphan', Date.now() + 1000); + const record = { type: 'attributes', attributeName: 'class', oldValue: '', target: root }; + observers[0].callback(new Array(6000).fill(record)); + const handle = window.__fsbChangeReportHandles.orphan; + assert.equal(handle.mutations.length, 5000); + assert.equal(handle.mutationCount, 6000); + timers.find(timer => timer.delay === 120000).callback(); + assert.equal(observers[0].connected, false); + assert.equal(window.__fsbChangeReportHandles.orphan, undefined); +}); + +test('a stopped change-report harvest reports the full mutation count', () => { + const { page, observers, timers, root } = harvestPage(); + page.start(null, 'a', Date.now() + 1000); + observers[0].callback(new Array(6000).fill({ type: 'attributes', attributeName: 'class', oldValue: '', target: root })); + const stopped = page.stop('a'); + assert.equal(stopped.mutations.length, 5000); + assert.equal(stopped.mutation_count, 6000); + assert.equal(timers.find(timer => timer.delay === 120000).cleared, true); +}); + +test('a change-report start that times out queues the stop for its own token', async () => { + const calls = []; + const priorChrome = global.chrome; + global.chrome = { + tabs: { get: async (id) => ({ id, url: 'https://example.com/' }) }, + scripting: { + executeScript(options) { + calls.push(options); + return options.func.name === '_fsbHarvestStartInPage' + ? new Promise(() => {}) + : Promise.resolve([{ result: null }]); + } + }, + storage: { local: { get: (_key, callback) => callback({}) }, onChanged: { addListener() {} } } + }; + try { + const modulePath = require.resolve('../extension/ws/mcp-tool-dispatcher.js'); + delete require.cache[modulePath]; + const loaded = require(modulePath); + loaded._setChangeReportsEnabledForTest(true); + const response = await loaded.wrapWithChangeReport({ + toolName: 'click', tabId: 3, params: { selector: '#go' }, + execute: async () => ({ success: true }) + }); + assert.equal(response.success, true); + assert.equal(response.change_report, undefined); + assert.equal(calls[0].func.name, '_fsbHarvestStartInPage'); + assert.equal(calls[1].func.name, '_fsbHarvestStopInPage'); + assert.equal(calls[1].args[0], calls[0].args[1]); + assert.ok(calls[0].args[2] <= Date.now()); + } finally { + global.chrome = priorChrome; + } +}); + +test('an injection that never settles does not pin the tab past its timeout', async () => { + const start = background.indexOf('const contentScriptInjectionFlights = new Map();'); + const end = background.indexOf('\nasync function ensureContentScriptInjectedUnlocked', start); + const source = background.slice(start, end).replace('12000', '20'); + let injections = 0; + const context = { + setTimeout, + ensureContentScriptInjectedUnlocked: () => (++injections === 1 ? new Promise(() => {}) : Promise.resolve(true)) + }; + const ensure = vm.runInNewContext(`${source}\nensureContentScriptInjected`, context); + const results = await Promise.allSettled([ensure(5), ensure(5)]); + assert.equal(injections, 1); + for (const result of results) { + assert.equal(result.status, 'rejected'); + assert.equal(result.reason.code, 'PAGE_UNRESPONSIVE'); + } + assert.equal(await ensure(5), true); + assert.equal(injections, 2); +}); + +test('an undelivered mutation is reported as not executed', async () => { + const start = bridge.indexOf(' async _sendToContentScript(tabId, message) {'); + const end = bridge.indexOf('\n async _handleGetTabs(', start); + const context = { + sendMessageWithRetry: async () => { + throw { message: 'Failed after 3 attempts: Could not establish connection. Receiving end does not exist.' }; + }, + setTimeout, clearTimeout + }; + const send = vm.runInNewContext(`({${bridge.slice(start, end)}})._sendToContentScript`, context); + const result = await send(9, { action: 'executeAction', tool: 'click' }); + assert.equal(result.errorCode, 'PAGE_UNRESPONSIVE'); + assert.equal(result.outcome, 'failed'); + assert.equal(result.mayHaveExecuted, false); +}); + +test('a direct send that loses its port after dispatch stays uncertain', async () => { + const start = bridge.indexOf(' async _sendToContentScript(tabId, message) {'); + const end = bridge.indexOf('\n async _handleGetTabs(', start); + const chrome = { runtime: {}, tabs: { + sendMessage(_tabId, _message, _options, callback) { + chrome.runtime.lastError = { message: 'The message port closed before a response was received.' }; + callback(); + delete chrome.runtime.lastError; + } + } }; + const context = { ensureContentScriptInjected: async () => true, chrome, Date, setTimeout, clearTimeout }; + const send = vm.runInNewContext(`({${bridge.slice(start, end)}})._sendToContentScript`, context); + const result = await send(9, { action: 'executeAction', tool: 'click' }); + assert.equal(result.outcome, 'unknown'); + assert.equal(result.mayHaveExecuted, true); +}); + +test('a retry that fails before sending is not reported as possibly executed', async () => { + const start = background.indexOf('async function sendMessageWithRetry('); + const end = background.indexOf('\n// Alternative action strategies', start); + let sent = 0; + let injections = 0; + const context = { + Date, Math, + chrome: { tabs: { + get: async () => ({ url: 'https://example.com' }), + sendMessage: async () => { + sent++; + throw new Error('Could not establish connection. Receiving end does not exist.'); + } + } }, + checkContentScriptHealth: async () => sent === 0, + ensureContentScriptInjected: async () => { + if (++injections > 1) { + const error = new Error('Content script injection timed out'); + error.code = 'PAGE_UNRESPONSIVE'; + throw error; + } + return true; + }, + classifyFailure: (error) => (/receiving end/i.test(error.message) ? 'communication' : 'unknown'), + FAILURE_TYPES: { BF_CACHE: 'bf_cache', COMMUNICATION: 'communication' }, + contentScriptHealth: new Map(), + automationLogger: { logComm() {}, logRecovery() {}, logTiming() {}, debug() {} }, + setTimeout: (callback) => { callback(); return 0; } + }; + const send = vm.runInNewContext(`${background.slice(start, end)}\nsendMessageWithRetry`, context); + await assert.rejects(send(7, { action: 'executeAction', tool: 'click' }, 2), + (error) => /injection timed out/.test(error.message)); + assert.equal(sent, 1); +}); diff --git a/tests/mcp-attachment-diagnostics.test.js b/tests/mcp-attachment-diagnostics.test.js new file mode 100644 index 000000000..46c73970f --- /dev/null +++ b/tests/mcp-attachment-diagnostics.test.js @@ -0,0 +1,79 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const attachment = { + extensionId: 'a'.repeat(32), + extensionVersion: '0.9.91', + installInstanceId: 'install-fixture', + normalWindowCount: 2, + connectedAt: '2026-09-29T00:00:00.000Z' +}; + +async function modules() { + return import('../mcp/build/diagnostics.js'); +} + +test('doctor classifies a connected extension with zero normal windows', async () => { + const diagnostics = await modules(); + const snapshot = diagnostics.applyDiagnosticClassification({ + versionParityOk: true, + extensionConnected: true, + bridgeMode: 'relay', + hubConnected: true, + extensionAttachment: { ...attachment, normalWindowCount: 0 }, + probeNotes: [], + activeTab: { url: '', restricted: true, pageType: 'No active tab' }, + contentScript: { ready: false } + }); + assert.equal(snapshot.diagnosticLayer, 'no_browser_window'); + assert.equal(snapshot.diagnosticCode, 'NO_BROWSER_WINDOW'); + assert.equal(snapshot.extensionAttachment.normalWindowCount, 0); +}); + +test('origin pin mismatch gives pair reset guidance', async () => { + const diagnostics = await modules(); + const snapshot = diagnostics.applyDiagnosticClassification({ + versionParityOk: true, + extensionConnected: false, + bridgeMode: 'hub', + hubConnected: true, + lastDisconnectReason: 'extension_origin_pin_mismatch', + probeNotes: [] + }); + assert.equal(snapshot.diagnosticLayer, 'auth'); + assert.equal(snapshot.diagnosticCode, 'ORIGIN_PIN_MISMATCH'); + assert.match(snapshot.nextAction, /pair --reset/); +}); + +for (const mode of ['hub', 'relay']) { + test(`${mode} diagnostics use the diagnostic route for accurate tab counts`, async () => { + const diagnostics = await modules(); + const calls = []; + const topology = { + instanceId: `fixture-${mode}`, mode, hubConnected: true, extensionConnected: true, + relayCount: mode === 'hub' ? 1 : 0, pendingRequestCount: 0, + activeHubInstanceId: 'fixture-hub', lastExtensionHeartbeatAt: Date.now(), + lastDisconnectReason: null, extensionAttachment: attachment + }; + const bridge = { + topology, isConnected: true, async connect() {}, disconnect() {}, + async sendAndWait(message) { + calls.push(message.type); + return { success: true, + activeTab: { id: 5, url: 'https://example.com', windowId: 1, restricted: false, pageType: 'Web page' }, + contentScript: { ready: true, portConnected: true }, + tabsSummary: { totalTabs: 7, activeTabId: 5 }, attachment }; + } + }; + const snapshot = await diagnostics.collectBridgeDiagnostics( + { waitForExtensionMs: 0, includeConfig: false, includeTabs: true }, + { bridgeFactory: () => bridge, readBridgeAuthState: () => null } + ); + assert.deepEqual(calls, ['mcp:get-diagnostics']); + assert.equal(snapshot.tabsSummary.totalTabs, 7); + assert.equal(snapshot.extensionAttachment.installInstanceId, attachment.installInstanceId); + assert.equal(snapshot.extensionAttachment.extensionId, 'a'.repeat(32)); + }); +} diff --git a/tests/mcp-bridge-background-dispatch.test.js b/tests/mcp-bridge-background-dispatch.test.js index c92107fa8..1e3bf0dcb 100644 --- a/tests/mcp-bridge-background-dispatch.test.js +++ b/tests/mcp-bridge-background-dispatch.test.js @@ -493,6 +493,9 @@ async function runListCredentialsSecretStripCase() { const harness = buildClientHarness({ fsbDispatchInternalMessage(request) { dispatchCalls.push(request); + if (request.action === 'getCredentialVaultStatus') { + return Promise.resolve({ configured: true, unlocked: true }); + } return Promise.resolve({ success: true, credentials: [ @@ -505,8 +508,9 @@ async function runListCredentialsSecretStripCase() { const client = harness.exports.mcpBridgeClient; const result = await client._routeMessage('mcp:list-credentials', {}, 'msg-1'); - assertEqual(dispatchCalls.length, 1, 'list-credentials dispatches once through the internal path'); - assertDeepEqual(dispatchCalls[0], { action: 'getAllCredentials' }, 'list-credentials sends the getAllCredentials action'); + assertEqual(dispatchCalls.length, 2, 'list-credentials checks vault state before listing'); + assertDeepEqual(dispatchCalls[0], { action: 'getCredentialVaultStatus' }, 'list-credentials reads vault status'); + assertDeepEqual(dispatchCalls[1], { action: 'getAllCredentials' }, 'list-credentials sends the getAllCredentials action'); assertEqual(result.success, true, 'list-credentials succeeds via internal dispatch'); assertDeepEqual( result.credentials, diff --git a/tests/mcp-bridge-client-lifecycle.test.js b/tests/mcp-bridge-client-lifecycle.test.js index df3b3d9de..485d84310 100644 --- a/tests/mcp-bridge-client-lifecycle.test.js +++ b/tests/mcp-bridge-client-lifecycle.test.js @@ -175,9 +175,22 @@ function createFakeWebSocketClass(options = {}) { this.finishClose(); } - finishClose() { + finishClose(detail) { this.readyState = FakeWebSocket.CLOSED; - if (typeof this.onclose === 'function') this.onclose(); + if (typeof this.onclose !== 'function') return; + // Zero-argument delivery is what every existing case relies on and what + // the client must keep surviving, so it stays the default. Only opt in to + // a CloseEvent when a case is specifically about the code or reason. + if (detail === undefined) { + this.onclose(); + return; + } + this.onclose(detail); + } + + closeWith(code, reason) { + this.closeCount += 1; + this.finishClose({ code, reason }); } send(payload) { @@ -252,6 +265,9 @@ this.__phase198 = { DELEGATION_START_REQUEST_TIMEOUT_MS: typeof DELEGATION_START_REQUEST_TIMEOUT_MS !== 'undefined' ? DELEGATION_START_REQUEST_TIMEOUT_MS : undefined, PROVIDER_AUTH_BEGIN_REQUEST_TIMEOUT_MS: typeof PROVIDER_AUTH_BEGIN_REQUEST_TIMEOUT_MS !== 'undefined' ? PROVIDER_AUTH_BEGIN_REQUEST_TIMEOUT_MS : undefined, ADAPTER_COMPATIBILITY_REQUEST_TIMEOUT_MS: typeof ADAPTER_COMPATIBILITY_REQUEST_TIMEOUT_MS !== 'undefined' ? ADAPTER_COMPATIBILITY_REQUEST_TIMEOUT_MS : undefined, + MCP_PING_MISS_LIMIT: typeof MCP_PING_MISS_LIMIT !== 'undefined' ? MCP_PING_MISS_LIMIT : undefined, + MCP_KEEPALIVE_CLOSE_GRACE_MS: typeof MCP_KEEPALIVE_CLOSE_GRACE_MS !== 'undefined' ? MCP_KEEPALIVE_CLOSE_GRACE_MS : undefined, + MCP_AUTH_BACKOFF_STEP_LIMIT: typeof MCP_AUTH_BACKOFF_STEP_LIMIT !== 'undefined' ? MCP_AUTH_BACKOFF_STEP_LIMIT : undefined, MCP_BRIDGE_PAIRING_KEY: typeof MCP_BRIDGE_PAIRING_KEY !== 'undefined' ? MCP_BRIDGE_PAIRING_KEY : undefined, FSB_EXT_PROTOCOL: typeof FSB_EXT_PROTOCOL !== 'undefined' ? FSB_EXT_PROTOCOL : undefined, lifecycleBus: typeof fsbAutomationLifecycleBus !== 'undefined' ? fsbAutomationLifecycleBus : null @@ -1600,6 +1616,311 @@ async function runDelegationHeartbeatCases() { } } +function latestTimeout(harness) { + return harness.timers.timeouts[harness.timers.timeouts.length - 1]; +} + +async function runAuthRefusalBackoffCase() { + console.log('\n--- daemon authorization refusal backoff ---'); + + const harness = buildClientHarness(); + const client = harness.exports.mcpBridgeClient; + assertEqual(harness.exports.MCP_AUTH_BACKOFF_STEP_LIMIT, 8, 'refusal backoff is bounded to eight escalation steps'); + + client.connect(); + await flushMicrotasks(); + const first = harness.sockets[0]; + first.open(); + assertEqual(client.getState().reconnectDelayMs, 2000, 'a successful upgrade still resets the base reconnect delay'); + assertEqual(client.getState().authRefusalStreak, 0, 'a fresh client carries no refusal streak'); + + first.closeWith(1008, 'Extension authorization required'); + await flushMicrotasks(); + assertEqual( + client.getState().lastDisconnectReason, + 'socket_close_1008:Extension authorization required', + 'the refusal code and reason survive into the persisted disconnect reason', + ); + assertEqual(client.getState().authRefusalStreak, 1, 'one refusal counts once'); + assertEqual(client.getState().reconnectDelayMs, 3000, 'the refusal overrides the reset the successful upgrade just performed'); + assertEqual(latestTimeout(harness).delay, 3000, 'the scheduled reconnect uses the escalated delay'); + + const expected = [4500, 6750, 10125, 15188, 22781, 30000, 30000]; + for (let step = 0; step < expected.length; step += 1) { + latestTimeout(harness).fn(); + await flushMicrotasks(); + const socket = harness.sockets[step + 1]; + assert(!!socket, `refusal cycle ${step + 2} opens one replacement socket`); + socket.open(); + assertEqual(client.getState().reconnectDelayMs, 2000, `refusal cycle ${step + 2} still sees the upgrade reset the delay`); + socket.closeWith(1008, 'Extension authorization required'); + await flushMicrotasks(); + assertEqual(client.getState().reconnectDelayMs, expected[step], `refusal ${step + 2} escalates instead of retrying at a flat rate`); + } + assertEqual(client.getState().authRefusalStreak, 8, 'the refusal streak stops at its bound'); + assertEqual(client.getState().status, 'reconnecting', 'a refused bridge stays in the ordinary reconnect state machine'); +} + +async function runCloseEventShapeCase() { + console.log('\n--- argument-free and coded close events ---'); + + { + const harness = buildClientHarness(); + const client = harness.exports.mcpBridgeClient; + client.connect(); + await flushMicrotasks(); + const socket = harness.sockets[0]; + socket.open(); + socket.close(); + await flushMicrotasks(); + assertEqual(client.getState().lastDisconnectReason, 'socket_close', 'an argument-free close keeps the legacy reason and never throws'); + assertEqual(client.getState().reconnectDelayMs, 2000, 'an argument-free close cannot escalate the backoff'); + assertEqual(client.getState().authRefusalStreak, 0, 'an argument-free close records no refusal'); + } + + { + const harness = buildClientHarness(); + const client = harness.exports.mcpBridgeClient; + client.connect(); + await flushMicrotasks(); + const socket = harness.sockets[0]; + socket.open(); + socket.closeWith(1006, ''); + await flushMicrotasks(); + assertEqual(client.getState().lastDisconnectReason, 'socket_close_1006', 'a reason-free coded close records only the code'); + assertEqual(client.getState().reconnectDelayMs, 2000, 'a non-1008 close leaves the backoff to the ordinary schedule'); + } +} + +async function runRevokedPairingSelfHealCase() { + console.log('\n--- revoked pairing credential self-heal ---'); + + { + const harness = buildClientHarness({ + session: { fsbMcpBridgePairing: { pairingCode: VALID_PAIRING_CODE, storedAt: Date.now() } } + }); + const client = harness.exports.mcpBridgeClient; + client.connect(); + await flushMicrotasks(); + const socket = harness.sockets[0]; + socket.open(); + await flushMicrotasks(); + const probe = JSON.parse(socket.sent[0]); + socket.receive({ + id: probe.id, + type: 'ext:response', + error: { code: 'ext_unauthorized', message: 'Extension authorization is unavailable', retryable: false } + }); + await flushMicrotasks(); + + assertEqual(client.getState().pairingStatus, 'expired', 'an unauthorized probe still reports expired'); + assertEqual(client._pairingCode, null, 'an unauthorized probe drops the rejected credential from memory'); + assertEqual( + harness.chrome.storage.session._dump().fsbMcpBridgePairing, + undefined, + 'an unauthorized probe removes the stored pairing record', + ); + + socket.closeWith(1008, 'Extension authorization revoked'); + await flushMicrotasks(); + assertEqual( + client.getState().authRefusalStreak, + 0, + 'a revocation arriving after the error frame already dropped the credential is not counted as a refusal', + ); + assertEqual(client.getState().reconnectDelayMs, 2000, 'the reconnect after a revocation goes out at the base delay'); + latestTimeout(harness).fn(); + await flushMicrotasks(); + const replacement = harness.sockets[1]; + assertEqual(replacement.protocols, undefined, 'the reconnect after a revoke goes out unpaired'); + replacement.open(); + await flushMicrotasks(); + assertEqual(replacement.sent.length, 0, 'an unpaired reconnect sends no auth probe and cannot be revoked again'); + assertEqual(client.getState().pairingStatus, 'unpaired', 'the unpaired reconnect reports honest unpaired status'); + } + + { + const harness = buildClientHarness({ + session: { fsbMcpBridgePairing: { pairingCode: VALID_PAIRING_CODE, storedAt: Date.now() } } + }); + const client = harness.exports.mcpBridgeClient; + client.connect(); + await flushMicrotasks(); + const socket = harness.sockets[0]; + socket.open(); + await flushMicrotasks(); + socket.closeWith(1008, 'Extension authorization revoked'); + await flushMicrotasks(); + assertEqual(client._pairingCode, null, 'a revoked close clears the credential without the error frame'); + assertEqual( + harness.chrome.storage.session._dump().fsbMcpBridgePairing, + undefined, + 'a revoked close removes the stored pairing record', + ); + assertEqual( + client.getState().reconnectDelayMs, + 2000, + 'clearing the credential makes the next handshake different, so it is not counted as a repeat refusal', + ); + } + + { + const harness = buildClientHarness({ + session: { fsbMcpBridgePairing: { pairingCode: VALID_PAIRING_CODE, storedAt: Date.now() } } + }); + const client = harness.exports.mcpBridgeClient; + client.connect(); + await flushMicrotasks(); + const socket = harness.sockets[0]; + socket.open(); + await flushMicrotasks(); + socket.closeWith(1008, 'Extension authorization required'); + await flushMicrotasks(); + // Asserted as booleans so a failure message can never echo the credential. + assertEqual(client._pairingCode === null, false, 'a slot-contention refusal preserves the credential'); + assertEqual( + harness.chrome.storage.session._dump().fsbMcpBridgePairing === undefined, + false, + 'a slot-contention refusal preserves the stored pairing record', + ); + assertEqual(client.getState().reconnectDelayMs, 3000, 'a slot-contention refusal escalates instead'); + } + + { + const harness = buildClientHarness(); + const client = harness.exports.mcpBridgeClient; + client.connect(); + await flushMicrotasks(); + const socket = harness.sockets[0]; + assertEqual(socket.protocols, undefined, 'the socket opens without a credential'); + socket.open(); + await flushMicrotasks(); + socket.closeWith(1008, 'Extension authorization revoked'); + await flushMicrotasks(); + assertEqual( + client.getState().authRefusalStreak, + 1, + 'revoking a socket that presented no credential is an ordinary refusal', + ); + assertEqual(client.getState().reconnectDelayMs, 3000, 'a repeated unpaired revocation backs off instead of looping at the base delay'); + } +} + +async function runOrdinaryKeepaliveLivenessCase() { + console.log('\n--- ordinary keepalive liveness ---'); + + const harness = buildClientHarness(); + const client = harness.exports.mcpBridgeClient; + assertEqual(harness.exports.MCP_PING_MISS_LIMIT, 3, 'the ordinary keepalive tolerates exactly three unanswered ticks'); + + client.connect(); + await flushMicrotasks(); + const socket = harness.sockets[0]; + socket.open(); + const ping = harness.timers.intervals.find((timer) => timer.delay === 25000 && !timer.cleared); + assert(!!ping, 'an open socket owns exactly one ordinary keepalive interval'); + + ping.fn(); + assertDeepEqual( + Object.keys(JSON.parse(socket.sent[0])).sort(), + ['ts', 'type'], + 'the ordinary keepalive ping keeps its exact nonce-free shape', + ); + socket.receive({ type: 'mcp:pong', ts: Date.now() }); + assertEqual(client._unansweredPings, 0, 'the two-key daemon pong is recorded instead of dropped'); + assert(Number.isSafeInteger(client.getState().lastPongAt), 'the two-key daemon pong records a liveness timestamp'); + + ping.fn(); + ping.fn(); + ping.fn(); + assertEqual(socket.sent.length, 4, 'three unanswered ticks still send their pings'); + assertEqual(socket.closeCount, 0, 'three unanswered ticks do not yet close the socket'); + + ping.fn(); + await flushMicrotasks(); + assertEqual(socket.closeCount, 1, 'the fourth unanswered tick closes the silent socket exactly once'); + assertEqual(socket.sent.length, 4, 'the closing tick sends no further ping'); + assertEqual(client.getState().lastDisconnectReason, 'keepalive_timeout', 'a keepalive close is labelled as such'); + assertEqual(client.getState().status, 'reconnecting', 'a keepalive close runs the ordinary reconnect path'); + assertEqual(client._ws, null, 'a keepalive close releases the socket so connect() can no longer no-op'); + assertEqual( + harness.timers.intervals.filter((t) => t.delay === 25000 && !t.cleared).length, + 0, + 'the keepalive interval is cleared by the close it caused', + ); + assertEqual( + harness.timers.timeouts.filter((t) => !t.cleared && t.delay >= 2000).length, + 1, + 'a keepalive close schedules exactly one reconnect timer', + ); + const grace = harness.timers.timeouts.find((t) => t.delay === harness.exports.MCP_KEEPALIVE_CLOSE_GRACE_MS); + assert(!!grace, 'a keepalive close arms a grace timer for a stalled closing handshake'); + grace.fn(); + await flushMicrotasks(); + assertEqual( + harness.timers.timeouts.filter((t) => !t.cleared && t.delay >= 2000).length, + 1, + 'the grace timer does nothing once the close has already run', + ); +} + +async function runStalledKeepaliveCloseCase() { + console.log('\n--- keepalive close whose handshake never completes ---'); + + const harness = buildClientHarness({ deferClose: true }); + const client = harness.exports.mcpBridgeClient; + assertEqual(harness.exports.MCP_KEEPALIVE_CLOSE_GRACE_MS, 1000, 'a stalled close is abandoned after one second, like a pairing reload'); + + client.connect(); + await flushMicrotasks(); + const socket = harness.sockets[0]; + socket.open(); + const ping = harness.timers.intervals.find((timer) => timer.delay === 25000 && !timer.cleared); + for (let tick = 0; tick < 4; tick += 1) ping.fn(); + await flushMicrotasks(); + + assertEqual(socket.closeCount, 1, 'the silent socket is asked to close'); + assertEqual(socket.readyState, 2, 'the peer never finishes the handshake, so the socket sits in CLOSING'); + assertEqual(client._ws, socket, 'nothing has reconnected yet while the close is pending'); + const reconnectTimers = () => harness.timers.timeouts.filter((t) => !t.cleared && t.delay >= 2000).length; + assertEqual(reconnectTimers(), 0, 'no reconnect is scheduled before the grace expires'); + + const grace = harness.timers.timeouts.find((t) => t.delay === 1000 && !t.cleared); + assert(!!grace, 'the keepalive close armed its grace timer'); + grace.fn(); + await flushMicrotasks(); + assertEqual(client._ws, null, 'the grace timer releases the stalled socket'); + assertEqual(client.getState().status, 'reconnecting', 'the grace timer runs the ordinary reconnect path'); + assertEqual(client.getState().lastDisconnectReason, 'keepalive_timeout', 'the abandoned close keeps its keepalive label'); + assertEqual(reconnectTimers(), 1, 'the grace timer schedules exactly one reconnect'); + assertEqual( + harness.timers.intervals.filter((t) => t.delay === 25000 && !t.cleared).length, + 0, + 'the keepalive interval stops with the abandoned socket', + ); + + socket.finishClose({ code: 1006, reason: '' }); + await flushMicrotasks(); + assertEqual(reconnectTimers(), 1, 'the late real close does not schedule a second reconnect'); + assertEqual(client.getState().lastDisconnectReason, 'keepalive_timeout', 'the late real close does not relabel the disconnect'); + + latestTimeout(harness).fn(); + await flushMicrotasks(); + assertEqual(harness.sockets.length, 2, 'the reconnect opens a fresh socket'); +} + +async function runReconnectAlarmBackstopCase() { + console.log('\n--- reconnect alarm backstop ---'); + + const harness = buildClientHarness({ throwOnConstruct: true }); + harness.exports.mcpBridgeClient.connect(); + await flushMicrotasks(); + const alarm = harness.chrome.alarms._created().find((entry) => entry.name === 'fsb-mcp-bridge-reconnect'); + assert(!!alarm, 'a failed connect arms the reconnect alarm'); + assertEqual(alarm.delayInMinutes, 0.5, 'the first wake still lands at the alarms floor'); + assertEqual(alarm.periodInMinutes, 1, 'the alarm repeats so one onopen clear cannot end the eviction backstop'); +} + async function run() { await runBrowserFirstReconnectCase(); await runServiceWorkerWakeCase(); @@ -1620,6 +1941,12 @@ async function run() { await runAsyncExtObserverCases(); runAsyncObserverSourceShapeCase(); await runDelegationHeartbeatCases(); + await runAuthRefusalBackoffCase(); + await runCloseEventShapeCase(); + await runRevokedPairingSelfHealCase(); + await runOrdinaryKeepaliveLivenessCase(); + await runStalledKeepaliveCloseCase(); + await runReconnectAlarmBackstopCase(); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/mcp-bridge-events.test.js b/tests/mcp-bridge-events.test.js new file mode 100644 index 000000000..9953e20be --- /dev/null +++ b/tests/mcp-bridge-events.test.js @@ -0,0 +1,219 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const repoRoot = path.resolve(__dirname, '..'); + +let passed = 0; +let failed = 0; + +function check(condition, message) { + if (condition) { + passed++; + console.log(` PASS: ${message}`); + return; + } + failed++; + console.error(` FAIL: ${message}`); +} + +function checkEqual(actual, expected, message) { + const ok = Object.is(actual, expected); + if (ok) { + passed++; + console.log(` PASS: ${message} (expected: ${expected}, got: ${actual})`); + return; + } + failed++; + console.error(` FAIL: ${message} (expected: ${expected}, got: ${actual})`); +} + +function withTempRoot(label, callback) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `${label}-`)); + try { + return callback(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function readLines(root, filename = 'bridge-events.jsonl') { + const target = path.join(root, filename); + if (!fs.existsSync(target)) return []; + return fs.readFileSync(target, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line)); +} + +async function run() { + const moduleUrl = pathToFileURL(path.join(repoRoot, 'mcp', 'build', 'bridge-events.js')).href; + const { + logBridgeEvent, + _resetBridgeEventCoalescing, + BRIDGE_LOG_COALESCE_WINDOW_MS, + BRIDGE_LOG_MAX_BYTES, + } = await import(moduleUrl); + + console.log('\n--- projection keeps the roster closed and the fields bounded ---'); + withTempRoot('bridge-events-projection', (root) => { + _resetBridgeEventCoalescing(); + const now = () => 1_000; + + checkEqual( + logBridgeEvent({ event: 'not_a_real_event', instanceId: 'abcd1234' }, { rootPath: root, now }), + false, + 'an event outside the roster is refused', + ); + checkEqual(readLines(root).length, 0, 'a refused event writes no line'); + + logBridgeEvent({ + event: 'upgrade_rejected_origin_pin', + instanceId: 'abcd1234', + origin: 'chrome-extension://aaaabbbbccccddddeeeeffffgggghhhh', + pinnedOrigin: 'chrome-extension://iiiijjjjkkkkllllmmmmnnnnoooopppp', + // Every field below is malformed and must be dropped rather than echoed. + reason: 'Not A Code', + closeCode: 99, + }, { rootPath: root, now }); + + const [record] = readLines(root); + checkEqual(record.event, 'upgrade_rejected_origin_pin', 'the event name is recorded'); + checkEqual(record.instanceId, 'abcd1234', 'a well-formed instance id is recorded'); + checkEqual(record.pinnedOrigin, 'chrome-extension://iiiijjjjkkkkllllmmmmnnnnoooopppp', 'the pinned origin is recorded'); + checkEqual(record.ts, '1970-01-01T00:00:01.000Z', 'the timestamp comes from the injected clock'); + checkEqual('reason' in record, false, 'a reason that is not a bounded code is dropped'); + checkEqual('closeCode' in record, false, 'a close code outside the WebSocket range is dropped'); + }); + + console.log('\n--- hub server errors are journaled with their errno ---'); + withTempRoot('bridge-events-hub-error', (root) => { + _resetBridgeEventCoalescing(); + checkEqual( + logBridgeEvent({ event: 'hub_server_error', instanceId: 'abcd1234', reason: 'emfile' }, { rootPath: root, now: () => 1_000 }), + true, + 'a hub server error is part of the roster', + ); + const [record] = readLines(root); + checkEqual(record && record.reason, 'emfile', 'the errno code is kept as the reason'); + checkEqual( + logBridgeEvent({ event: 'hub_server_error', instanceId: 'abcd1234', reason: 'emfile' }, { rootPath: root, now: () => 2_000 }), + false, + 'a repeating accept failure is coalesced, which is what gates its stderr line', + ); + checkEqual( + logBridgeEvent({ event: 'hub_listener_lost', instanceId: 'abcd1234', reason: 'ebadf' }, { rootPath: root, now: () => 3_000 }), + true, + 'a lost listener is journaled even inside an accept-failure window', + ); + const lost = readLines(root).find((line) => line.event === 'hub_listener_lost'); + checkEqual(lost && lost.reason, 'ebadf', 'the lost listener keeps its own errno'); + }); + + console.log('\n--- credentials and unbounded text never reach the file ---'); + withTempRoot('bridge-events-redaction', (root) => { + _resetBridgeEventCoalescing(); + logBridgeEvent({ + event: 'extension_closed', + instanceId: 'not a hex instance id', + origin: `chrome-extension://abc?secret=fsb-auth.${'A'.repeat(43)}`, + closeCode: 1008, + reason: 'extension_auth_revoked', + }, { rootPath: root, now: () => 2_000 }); + + const [record] = readLines(root); + const serialized = JSON.stringify(record); + checkEqual(serialized.includes('fsb-auth.'), false, 'a credential smuggled through the origin never reaches the file'); + checkEqual('origin' in record, false, 'an origin that is not an exact extension origin is dropped'); + checkEqual('instanceId' in record, false, 'an instance id that is not lowercase hex is dropped'); + checkEqual(record.closeCode, 1008, 'a close code inside the WebSocket range is kept'); + checkEqual(record.reason, 'extension_auth_revoked', 'a bounded reason code is kept'); + }); + + console.log('\n--- coalescing bounds a hot retry loop ---'); + withTempRoot('bridge-events-coalescing', (root) => { + _resetBridgeEventCoalescing(); + const origin = 'chrome-extension://aaaabbbbccccddddeeeeffffgggghhhh'; + let clock = 10_000; + const now = () => clock; + + checkEqual( + logBridgeEvent({ event: 'extension_slot_refused', origin }, { rootPath: root, now }), + true, + 'the first refusal in a window is written', + ); + let suppressed = 0; + for (let i = 0; i < 500; i += 1) { + clock += 10; + if (!logBridgeEvent({ event: 'extension_slot_refused', origin }, { rootPath: root, now })) suppressed += 1; + } + checkEqual(suppressed, 500, 'every repeat inside the window is coalesced away'); + checkEqual(readLines(root).length, 1, 'a 500-attempt loop leaves exactly one line'); + + clock += BRIDGE_LOG_COALESCE_WINDOW_MS; + checkEqual( + logBridgeEvent({ event: 'extension_slot_refused', origin }, { rootPath: root, now }), + true, + 'the first refusal after the window reopens is written', + ); + const lines = readLines(root); + checkEqual(lines.length, 2, 'reopening the window writes a second line'); + checkEqual(lines[1].suppressed, 500, 'the reopened line reports how many attempts it stood for'); + + // A different origin is a different story and must not be coalesced into + // the first one. + checkEqual( + logBridgeEvent({ + event: 'extension_slot_refused', + origin: 'chrome-extension://iiiijjjjkkkkllllmmmmnnnnoooopppp', + }, { rootPath: root, now }), + true, + 'a different origin is coalesced separately', + ); + }); + + console.log('\n--- rotation keeps the journal bounded ---'); + withTempRoot('bridge-events-rotation', (root) => { + _resetBridgeEventCoalescing(); + const logPath = path.join(root, 'bridge-events.jsonl'); + fs.writeFileSync(logPath, 'x'.repeat(BRIDGE_LOG_MAX_BYTES + 1), 'utf8'); + + let clock = 30_000; + logBridgeEvent({ event: 'extension_reaped', reason: 'pong_timeout' }, { + rootPath: root, + now: () => (clock += BRIDGE_LOG_COALESCE_WINDOW_MS), + }); + + check(fs.existsSync(path.join(root, 'bridge-events.1.jsonl')), 'an oversized journal is rotated aside'); + checkEqual(readLines(root).length, 1, 'the fresh journal holds only the new line'); + }); + + console.log('\n--- a broken destination never propagates ---'); + withTempRoot('bridge-events-failclosed', (root) => { + _resetBridgeEventCoalescing(); + checkEqual( + logBridgeEvent({ event: 'extension_closed' }, { rootPath: 'relative/path', now: () => 40_000 }), + false, + 'a non-absolute root is refused rather than resolved', + ); + // The directory is replaced by a file, so every filesystem call below throws. + const blocked = path.join(root, 'blocked'); + fs.writeFileSync(blocked, 'not a directory', 'utf8'); + checkEqual( + logBridgeEvent({ event: 'extension_closed' }, { rootPath: blocked, now: () => 50_000 }), + false, + 'an unusable destination reports failure instead of throwing', + ); + }); + + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((error) => { + failed++; + console.error(' FAIL: Test harness failed:', error); + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); + process.exit(1); +}); diff --git a/tests/mcp-bridge-topology.test.js b/tests/mcp-bridge-topology.test.js index 5338736dc..bb9fe595b 100644 --- a/tests/mcp-bridge-topology.test.js +++ b/tests/mcp-bridge-topology.test.js @@ -156,13 +156,14 @@ function createBrowserSocket(port, options = {}) { : options.stableProtocol ? ['fsb-ext-v1'] : undefined; + const socketOptions = { + headers: options.origin ? { Origin: options.origin } : undefined, + // false stands in for a peer that has stopped answering protocol pings. + ...(options.autoPong === undefined ? {} : { autoPong: options.autoPong }), + }; const socket = protocols - ? new WebSocket(`ws://127.0.0.1:${port}`, protocols, { - headers: options.origin ? { Origin: options.origin } : undefined, - }) - : new WebSocket(`ws://127.0.0.1:${port}`, { - headers: options.origin ? { Origin: options.origin } : undefined, - }); + ? new WebSocket(`ws://127.0.0.1:${port}`, protocols, socketOptions) + : new WebSocket(`ws://127.0.0.1:${port}`, socketOptions); const timeout = setTimeout(() => { socket.terminate(); reject(new Error(`browser socket did not open on ${port}`)); @@ -1415,6 +1416,315 @@ async function runUnprivilegedCannotDisplaceExtension(WebSocketBridge, auth) { }); } +// The slot is freed only by the incumbent socket's own close, so before the +// liveness reaper existed an extension whose worker died without the socket +// closing held it indefinitely -- and every legitimate reconnect was refused +// for as long as that lasted. A real ws client answers protocol pings from +// under the application, exactly as a browser does, so this exercises the +// application tier: the incumbent proves it speaks mcp:ping, then stops. +async function runStaleIncumbentIsReaped(WebSocketBridge, auth) { + await withTempHome('bridge-stale-incumbent', async (home) => { + const port = await getFreePort(); + const authPath = auth.getBridgeAuthPath(home); + let state = auth.rotateBridgeSessionSecret(authPath, 23_000); + state = auth.bindAllowedExtensionOrigin('chrome-extension://stale-incumbent-extension', authPath); + const hub = new WebSocketBridge({ + port, + host: '127.0.0.1', + instanceId: 'stale-incumbent-hub', + handshakeTimeoutMs: 40, + extensionPingIntervalMs: 30, + extensionHeartbeatTimeoutMs: 60, + }); + const resources = { sockets: [], bridges: [hub] }; + + try { + await hub.connect(); + const incumbent = await createBrowserSocket(port, { + origin: 'chrome-extension://stale-incumbent-extension', + pairingCode: auth.formatPairingCode(state), + }); + resources.sockets.push(incumbent); + await waitFor(() => hub.topology.extensionConnected === true, 'incumbent extension registration'); + const incumbentServerSocket = hub.extensionClient; + + // One application heartbeat arms the application tier for this socket. + incumbent.send(JSON.stringify({ type: 'mcp:ping', ts: Date.now() })); + await waitFor( + () => hub.topology.lastExtensionHeartbeatAt !== null, + 'incumbent application heartbeat recorded', + ); + + const incumbentClosed = waitForSocketClose(incumbent, 4000); + // Observed now so a failure elsewhere in the case reports as a FAIL rather + // than crashing the suite with an unhandled rejection. + incumbentClosed.catch(() => {}); + await waitFor( + () => hub.topology.extensionConnected === false, + 'stale incumbent is reaped', + 4000, + ); + await incumbentClosed; + assertEqual(hub.extensionClient, null, 'reaping the stale incumbent frees the extension slot'); + assertEqual( + hub.topology.lastDisconnectReason, + 'extension_reaped_heartbeat_timeout', + 'the application tier names itself as the cause', + ); + + const successor = await createBrowserSocket(port, { + origin: 'chrome-extension://stale-incumbent-extension', + }); + resources.sockets.push(successor); + await waitFor(() => hub.topology.extensionConnected === true, 'successor extension registration'); + assert( + hub.extensionClient !== incumbentServerSocket, + 'an unpaired successor wins the freed slot rather than being refused', + ); + } finally { + await cleanup(resources); + } + }); +} + +// The transport tier: a peer that stops answering protocol pings is gone even +// if it never proved it speaks mcp:ping, so it must not hold the slot. +async function runUnresponsiveIncumbentIsReaped(WebSocketBridge) { + await withTempHome('bridge-unresponsive-incumbent', async () => { + const port = await getFreePort(); + const hub = new WebSocketBridge({ + port, + host: '127.0.0.1', + instanceId: 'unresponsive-incumbent-hub', + handshakeTimeoutMs: 40, + extensionPingIntervalMs: 30, + extensionHeartbeatTimeoutMs: 60_000, + }); + const resources = { sockets: [], bridges: [hub] }; + + try { + await hub.connect(); + const incumbent = await createBrowserSocket(port, { + origin: 'chrome-extension://unresponsive-incumbent-extension', + autoPong: false, + }); + resources.sockets.push(incumbent); + await waitFor(() => hub.topology.extensionConnected === true, 'unresponsive incumbent registration'); + + const incumbentClosed = waitForSocketClose(incumbent, 4000); + // Observed now so a failure elsewhere in the case reports as a FAIL rather + // than crashing the suite with an unhandled rejection. + incumbentClosed.catch(() => {}); + await waitFor( + () => hub.topology.extensionConnected === false, + 'unresponsive incumbent is reaped', + 4000, + ); + await incumbentClosed; + assertEqual(hub.extensionClient, null, 'reaping the unresponsive incumbent frees the extension slot'); + assertEqual( + hub.topology.lastDisconnectReason, + 'extension_reaped_pong_timeout', + 'the transport tier names itself as the cause', + ); + } finally { + await cleanup(resources); + } + }); +} + +// An extension build that predates mcp:ping still answers protocol pings. The +// application tier must stay disarmed for it, however long it stays quiet. +async function runQuietLegacyExtensionIsKept(WebSocketBridge) { + await withTempHome('bridge-quiet-legacy', async () => { + const port = await getFreePort(); + const hub = new WebSocketBridge({ + port, + host: '127.0.0.1', + instanceId: 'quiet-legacy-hub', + handshakeTimeoutMs: 40, + extensionPingIntervalMs: 30, + extensionHeartbeatTimeoutMs: 60, + }); + const resources = { sockets: [], bridges: [hub] }; + + try { + await hub.connect(); + const legacy = await createBrowserSocket(port, { + origin: 'chrome-extension://quiet-legacy-extension', + }); + resources.sockets.push(legacy); + await waitFor(() => hub.topology.extensionConnected === true, 'legacy extension registration'); + const registered = hub.extensionClient; + + await sleep(400); + assertEqual(hub.topology.extensionConnected, true, 'a quiet extension that answers protocol pings stays connected'); + assert(hub.extensionClient === registered, 'the quiet extension keeps the slot it registered in'); + assertEqual(legacy.readyState, WebSocket.OPEN, 'the quiet extension socket is never closed'); + } finally { + await cleanup(resources); + } + }); +} + +function answerBridgeRequests(socket) { + socket.on('message', (raw) => { + let message; + try { + message = JSON.parse(raw.toString()); + } catch (_error) { + return; + } + if (typeof message.id !== 'string' || message.type === 'mcp:unanswered') return; + socket.send(JSON.stringify({ + id: message.id, + type: 'mcp:response', + payload: { success: true, answered: message.type }, + })); + }); +} + +// Node keeps a server listening after an accept failure such as EMFILE, so an +// 'error' event on a listening hub is not an outage and must not become one. +async function runHubSurvivesAcceptError(WebSocketBridge) { + await withTempHome('bridge-hub-accept-error', async () => { + const port = await getFreePort(); + const hub = new WebSocketBridge({ port, host: '127.0.0.1', instanceId: 'accept-error-hub' }); + const resources = { sockets: [], bridges: [hub] }; + + try { + await hub.connect(); + const extension = await createBrowserSocket(port, { + origin: 'chrome-extension://accept-error-extension', + }); + resources.sockets.push(extension); + answerBridgeRequests(extension); + await waitFor(() => hub.topology.extensionConnected === true, 'extension registration'); + const server = hub.httpServer; + + server.emit('error', Object.assign(new Error('accept EMFILE'), { code: 'EMFILE' })); + + assertEqual(hub.currentMode, 'hub', 'an accept error leaves the bridge in hub mode'); + assert(hub.httpServer === server && server.listening, 'an accept error keeps the same listener'); + assertEqual(hub.topology.extensionConnected, true, 'an accept error keeps the extension attached'); + const response = await hub.sendAndWait({ type: 'mcp:get-tabs', payload: {} }, { timeout: 1000 }); + assertEqual(response.answered, 'mcp:get-tabs', 'requests still reach the extension after an accept error'); + } finally { + await cleanup(resources); + } + }); +} + +// A hub whose listener is really gone must release everything it holds and +// compete for the port again, rather than sit in a mode nothing can reach. +async function runHubRebindsAfterListenerLoss(WebSocketBridge, bridgeModule) { + await withTempHome('bridge-hub-listener-loss', async (home) => { + const port = await getFreePort(); + const hub = new WebSocketBridge({ + port, + host: '127.0.0.1', + instanceId: 'listener-loss-hub', + promotionJitterMs: 1, + }); + const resources = { sockets: [], bridges: [hub] }; + + try { + await hub.connect(); + const extension = await createBrowserSocket(port, { + origin: 'chrome-extension://listener-loss-extension', + }); + resources.sockets.push(extension); + answerBridgeRequests(extension); + await waitFor(() => hub.topology.extensionConnected === true, 'extension registration'); + extension.send(JSON.stringify({ + type: 'mcp:extension-state', + extensionId: 'a'.repeat(32), + extensionVersion: '1.0.0', + installInstanceId: 'listener-loss-install', + normalWindowCount: 1, + connectedAt: null, + })); + await waitFor(() => hub.topology.extensionAttachment !== null, 'extension attachment report'); + + const pending = hub.sendAndWait({ type: 'mcp:unanswered', payload: {} }, { timeout: 5000 }) + .then(() => null, (error) => error); + const extensionClosed = waitForSocketClose(extension, 2000); + // Observed now so a failure elsewhere in the case reports as a FAIL rather + // than crashing the suite with an unhandled rejection. + extensionClosed.catch(() => {}); + const lostServer = hub.httpServer; + // An accept failure just before the loss must not coalesce away its record. + lostServer.emit('error', Object.assign(new Error('accept EMFILE'), { code: 'EMFILE' })); + lostServer.close(); + const startedAt = Date.now(); + lostServer.emit('error', Object.assign(new Error('listener lost'), { code: 'EBADF' })); + assertEqual(hub.topology.extensionAttachment, null, 'the departed extension is no longer reported as attached'); + const journal = fs.readFileSync(path.join(home, '.fsb', 'agent-runtime', 'bridge-events.jsonl'), 'utf8') + .trim().split('\n').map((line) => JSON.parse(line)); + assert(journal.some((record) => record.event === 'hub_listener_lost' && record.reason === 'ebadf'), + 'the lost listener is journaled after an accept failure'); + + const error = await pending; + assert(error instanceof Error, 'an in-flight request is rejected when the listener is lost'); + assert(Date.now() - startedAt < 1000, 'the rejection is immediate rather than waiting out the request timeout'); + assertEqual(bridgeModule.isBridgeDisconnectError(error), true, 'the rejection arms the sw_evicted recovery'); + assertEqual(bridgeModule.bridgeDisconnectReason(error), 'hub_server_error', 'the rejection carries the real cause'); + await extensionClosed; + assert(true, 'the extension socket is closed so it reconnects to whoever binds next'); + + await waitFor( + () => hub.currentMode === 'hub' && hub.httpServer !== lostServer && hub.httpServer?.listening === true, + 'hub re-binds the port', + 5000, + ); + const successor = await createBrowserSocket(port, { + origin: 'chrome-extension://listener-loss-extension', + }); + resources.sockets.push(successor); + answerBridgeRequests(successor); + await waitFor(() => hub.topology.extensionConnected === true, 'extension re-registration'); + const response = await hub.sendAndWait({ type: 'mcp:get-tabs', payload: {} }, { timeout: 1000 }); + assertEqual(response.answered, 'mcp:get-tabs', 'requests reach the extension again after the hub re-binds'); + } finally { + await cleanup(resources); + } + }); +} + +async function runStdinShutdownHelper() { + const { PassThrough } = require('node:stream'); + const helperUrl = pathToFileURL(path.join(repoRoot, 'mcp', 'build', 'stdin-shutdown.js')).href; + const { shutdownWhenStdinEnds } = await import(helperUrl); + + const ended = new PassThrough(); + ended.end(); + ended.resume(); + await new Promise((resolve) => ended.once('end', resolve)); + let endedCalls = 0; + const firedImmediately = shutdownWhenStdinEnds(ended, () => { endedCalls += 1; }); + assertEqual(firedImmediately, true, 'stdin that already hit EOF is reported as ended'); + assertEqual(endedCalls, 1, 'stdin that already hit EOF shuts down synchronously'); + + const live = new PassThrough(); + let liveCalls = 0; + assertEqual(shutdownWhenStdinEnds(live, () => { liveCalls += 1; }), false, 'open stdin is left running'); + assertEqual(liveCalls, 0, 'open stdin does not shut down'); + const closed = new Promise((resolve) => live.once('close', resolve)); + live.end(); + await closed; + assertEqual(liveCalls, 1, 'end followed by close shuts down exactly once'); + + const source = fs.readFileSync(path.join(repoRoot, 'mcp', 'src', 'index.ts'), 'utf8'); + const stdioSource = source.slice( + source.indexOf('async function runStdioServer'), + source.indexOf('async function runHttpMode'), + ); + assert( + /shutdownWhenStdinEnds\(process\.stdin,[\s\S]*?shutdown\(\);/.test(stdioSource), + 'the stdio server shuts down when the host closes stdin', + ); +} + async function runAuthStatusProbe(WebSocketBridge, auth) { await withTempHome('bridge-auth-status', async (home) => { const port = await getFreePort(); @@ -2153,6 +2463,7 @@ async function runHubExitPromotion(WebSocketBridge) { async function run() { const WebSocketBridge = await loadBridgeClass(); + const bridgeModule = await import(pathToFileURL(path.join(repoRoot, 'mcp', 'build', 'bridge.js')).href); const auth = await loadAuthModule(); const lifecycleModule = await loadServeDelegationModule(); @@ -2173,6 +2484,12 @@ async function run() { await runCase('active socket revocation and new-ID rebind after reset', () => runActiveSocketRevocation(WebSocketBridge, auth, true)); await runCase('hub exits with an active ext request', () => runHubExitWithActiveExtRequest(WebSocketBridge, auth)); await runCase('capable relay exits mid ext frame', () => runCapableRelayExitMidExtFrame(WebSocketBridge, auth)); + await runCase('stale incumbent is reaped and the slot is reusable', () => runStaleIncumbentIsReaped(WebSocketBridge, auth)); + await runCase('unresponsive incumbent is reaped by the transport tier', () => runUnresponsiveIncumbentIsReaped(WebSocketBridge)); + await runCase('quiet legacy extension is never reaped', () => runQuietLegacyExtensionIsKept(WebSocketBridge)); + await runCase('hub keeps serving through an accept error', () => runHubSurvivesAcceptError(WebSocketBridge)); + await runCase('hub that loses its listener tears down and re-binds', () => runHubRebindsAfterListenerLoss(WebSocketBridge, bridgeModule)); + await runCase('stdio server exits when the host closes stdin', () => runStdinShutdownHelper()); await runCase('hub-exit-promotion', () => runHubExitPromotion(WebSocketBridge)); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); diff --git a/tests/mcp-client-inventory.test.js b/tests/mcp-client-inventory.test.js index 74881867c..284b1cefe 100644 --- a/tests/mcp-client-inventory.test.js +++ b/tests/mcp-client-inventory.test.js @@ -404,8 +404,8 @@ async function main() { ); assert.match( indexSource, - /const lifecycle = await startServeDelegation\(\{\s*host,\s*port,\s*dependencies:\s*\{\s*prepareBridgeAuth: \(\) => \{\s*rotateBridgeSessionSecret\(\);\s*\},\s*\},\s*\}\);/, - 'serve mode composes inventory startup with post-bind bridge-auth rotation', + /const lifecycle = await startServeDelegation\(\{\s*host,\s*port,\s*dependencies:\s*\{\s*prepareBridgeAuth: \(\) => \{\s*if \(!readBridgeAuthState\(\)\) rotateBridgeSessionSecret\(\);\s*\},\s*\},\s*\}\);/, + 'serve mode composes inventory startup with post-bind bridge-auth minting', ); const serveLifecycleSource = fs.readFileSync( path.join(repoRoot, 'mcp', 'src', 'agent-providers', 'serve-delegation.ts'), diff --git a/tests/mcp-diagnostics-status.test.js b/tests/mcp-diagnostics-status.test.js index 4e4aeca8a..471695672 100644 --- a/tests/mcp-diagnostics-status.test.js +++ b/tests/mcp-diagnostics-status.test.js @@ -1,7 +1,7 @@ 'use strict'; const path = require('path'); -const { pathToFileURL } = require('url'); +const { fileURLToPath, pathToFileURL } = require('url'); let passed = 0; let failed = 0; @@ -246,11 +246,38 @@ function makeNativeHostInspection(overrides = {}) { }; } +function makeDisconnectedSnapshot(lastDisconnectReason) { + return makeSnapshot({ + extensionConnected: false, + lastDisconnectReason, + bridgeTopology: { ...makeSnapshot().bridgeTopology, extensionConnected: false, lastDisconnectReason }, + }); +} + async function run() { const diagnosticsUrl = pathToFileURL(path.join(repoRoot, 'mcp', 'build', 'diagnostics.js')).href; const indexUrl = pathToFileURL(path.join(repoRoot, 'mcp', 'build', 'index.js')).href; const diagnostics = await import(diagnosticsUrl); - const indexModule = await import(indexUrl); + // Importing the CLI entry runs its main(). With no command that starts the + // stdio server, which attaches to whatever hub owns the real bridge port and + // exits the moment stdin is closed -- ending this suite early with status 0. + // `help` is the one command that is synchronous and side-effect free. + const savedArgv = process.argv; + const savedLog = console.log; + const importOutput = []; + process.argv = [process.execPath, fileURLToPath(indexUrl), 'help']; + console.log = (...args) => { importOutput.push(args.join(' ')); }; + let indexModule; + try { + indexModule = await import(indexUrl); + } finally { + process.argv = savedArgv; + console.log = savedLog; + } + assert( + importOutput.join('\n').includes('Usage:'), + 'importing the CLI entry takes the inert help branch instead of starting a server', + ); const compatibilityUrl = pathToFileURL( path.join(repoRoot, 'mcp', 'build', 'agent-providers', 'compatibility.js'), ).href; @@ -483,6 +510,14 @@ async function run() { extensionConnected: false, bridgeTopology: { ...makeSnapshot().bridgeTopology, extensionConnected: false }, }), 'extension'], + ['revoked authorization', makeDisconnectedSnapshot('extension_auth_revoked'), 'auth'], + ['policy close', makeDisconnectedSnapshot('extension_policy_closed'), 'auth'], + ['plain extension disconnect', makeDisconnectedSnapshot('extension_disconnected'), 'extension'], + ['reaped extension', makeDisconnectedSnapshot('extension_reaped_pong_timeout'), 'extension'], + ['reconnected after a revocation', makeSnapshot({ + lastDisconnectReason: 'extension_auth_revoked', + bridgeTopology: { ...makeSnapshot().bridgeTopology, lastDisconnectReason: 'extension_auth_revoked' }, + }), 'healthy'], ['content_script', makeSnapshot({ contentScript: { ready: false, @@ -527,6 +562,11 @@ async function run() { assert(packageDoctor.includes('Why:'), 'doctor output includes Why:'); assert(packageDoctor.includes('Next action:'), 'doctor output includes Next action:'); assert(packageDoctor.includes('Package / version parity'), 'doctor output includes package label'); + const authDoctor = indexModule.formatDoctor( + diagnostics.applyDiagnosticClassification(makeDisconnectedSnapshot('extension_auth_revoked')), + ); + assert(authDoctor.includes('Bridge authorization'), 'doctor output names the authorization layer'); + assert(authDoctor.includes('pair --reset'), 'doctor output gives the pairing reset as the next action'); const formattedSnapshot = makeSnapshot(); const formattedDoctor = indexModule.formatDoctor(formattedSnapshot); diff --git a/tests/mcp-http-security.test.js b/tests/mcp-http-security.test.js new file mode 100644 index 000000000..b9daef958 --- /dev/null +++ b/tests/mcp-http-security.test.js @@ -0,0 +1,92 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const test = require('node:test'); + +function request(port, { path = '/health', host = `127.0.0.1:${port}`, origin, method = 'GET', hostname = '127.0.0.1' } = {}) { + return new Promise((resolve, reject) => { + const headers = { Host: host }; + if (origin !== undefined) headers.Origin = origin; + const req = http.request({ hostname, port, path, method, headers }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); + }); + req.on('error', reject); + req.end(); + }); +} + +test('local HTTP accepts only loopback Host and requests without Origin', async () => { + const { startHttpServer } = await import('../mcp/build/http.js'); + const topology = { + mode: 'hub', extensionConnected: true, hubConnected: true, relayCount: 0, + activeHubInstanceId: null, + extensionAttachment: { extensionId: 'a'.repeat(32), extensionVersion: '0.9.91', + installInstanceId: 'install-fixture', normalWindowCount: 0, + connectedAt: '2026-09-29T00:00:00.000Z' }, + }; + const running = await startHttpServer({ + host: '127.0.0.1', port: 0, + bridge: { topology }, queue: { isRunning: false }, + }); + const port = Number(new URL(running.healthEndpoint).port); + try { + const good = await request(port); + assert.equal(good.status, 200); + assert.equal(JSON.parse(good.body).ok, true); + assert.deepEqual(JSON.parse(good.body).extensionAttachment, topology.extensionAttachment); + assert.equal(good.headers['access-control-allow-origin'], undefined); + + const initialize = await fetch(running.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { + protocolVersion: '2025-03-26', capabilities: {}, + clientInfo: { name: 'loopback-test', version: '1' } + } }) + }); + assert.equal(initialize.status, 200); + assert.ok(initialize.headers.get('mcp-session-id')); + assert.equal((await initialize.json()).result.serverInfo.name, 'fsb'); + + for (const path of ['/health', '/mcp']) { + assert.equal((await request(port, { path, host: `evil.example:${port}` })).status, 403); + assert.equal((await request(port, { path, origin: 'https://evil.example' })).status, 403); + assert.equal((await request(port, { path, origin: 'null', method: 'OPTIONS' })).status, 403); + } + assert.equal((await request(port, { host: `localhost:${port}` })).status, 200); + } finally { + await running.close(); + } +}); + +test('local HTTP serves requests on the IPv6 loopback', async (t) => { + const { startHttpServer } = await import('../mcp/build/http.js'); + const topology = { mode: 'hub', extensionConnected: false, hubConnected: true, relayCount: 0 }; + let running; + try { + running = await startHttpServer({ host: '::1', port: 0, bridge: { topology }, queue: { isRunning: false } }); + } catch (err) { + if (['EADDRNOTAVAIL', 'EAFNOSUPPORT'].includes(err.code)) return t.skip('no IPv6 loopback on this host'); + throw err; + } + try { + assert.match(running.healthEndpoint, /^http:\/\/\[::1\]:\d+\/health$/); + const port = Number(new URL(running.healthEndpoint).port); + const health = await request(port, { hostname: '::1', host: `[::1]:${port}` }); + assert.equal(health.status, 200); + assert.equal(JSON.parse(health.body).ok, true); + assert.equal((await request(port, { hostname: '::1', host: `[::1]:${port}`, path: '/nope' })).status, 404); + } finally { + await running.close(); + } +}); + +test('local HTTP refuses a non-loopback bind', async () => { + const { startHttpServer } = await import('../mcp/build/http.js'); + await assert.rejects( + startHttpServer({ host: '0.0.0.0', port: 0, bridge: {}, queue: {} }), + /loopback --host/, + ); +}); diff --git a/tests/mcp-lifecycle-smoke.test.js b/tests/mcp-lifecycle-smoke.test.js index 7300a5cb7..dce93da64 100644 --- a/tests/mcp-lifecycle-smoke.test.js +++ b/tests/mcp-lifecycle-smoke.test.js @@ -1,5 +1,13 @@ 'use strict'; +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +// Bridge pairing is machine-local. Keep smoke sockets independent of the +// developer's active extension pin and credential. +const smokeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'fsb-bridge-smoke-')); +process.env.HOME = smokeHome; + const { buildClientHarness, cleanupResources, @@ -238,6 +246,7 @@ async function run() { await runCase('relay recovery', runRelayRecoveryCase); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); + fs.rmSync(smokeHome, { recursive: true, force: true }); process.exit(failed > 0 ? 1 : 0); } @@ -245,5 +254,6 @@ run().catch((error) => { failed++; console.error(' FAIL: Test harness failed:', error); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); + fs.rmSync(smokeHome, { recursive: true, force: true }); process.exit(1); }); diff --git a/tests/mcp-recovery-messaging.test.js b/tests/mcp-recovery-messaging.test.js index 612854c1e..a6abcb287 100644 --- a/tests/mcp-recovery-messaging.test.js +++ b/tests/mcp-recovery-messaging.test.js @@ -171,6 +171,15 @@ async function run() { assert(!text.includes('Refresh page state'), 'session_not_found fixture does NOT use the misleading Refresh page state hint'); }, }, + { + label: 'uncertain_mutation', + input: { success: false, outcome: 'unknown', mayHaveExecuted: true, error: 'Port closed after dispatch' }, + checks(text) { + assert(text.includes('Action outcome unknown'), 'uncertain action names the outcome'); + assert(text.includes('Inspect the current page'), 'uncertain action requires inspection'); + assert(!text.includes('then retry.'), 'uncertain action never recommends an immediate retry'); + }, + }, ]; for (const fixture of fixtures) { diff --git a/tests/mcp-reverse-channel-contract.test.js b/tests/mcp-reverse-channel-contract.test.js index fdcc9e7c9..35cb55d10 100644 --- a/tests/mcp-reverse-channel-contract.test.js +++ b/tests/mcp-reverse-channel-contract.test.js @@ -188,8 +188,8 @@ async function run() { const indexSource = fs.readFileSync(path.join(repoRoot, 'mcp', 'src', 'index.ts'), 'utf8'); assert(indexSource.includes("import { startServeDelegation } from './agent-providers/serve-delegation.js';")); assert( - /const lifecycle = await startServeDelegation\(\{\s*host,\s*port,\s*dependencies:\s*\{\s*prepareBridgeAuth: \(\) => \{\s*rotateBridgeSessionSecret\(\);\s*\},\s*\},\s*\}\);/.test(indexSource), - 'serve startup injects secret rotation only through the post-bind lifecycle hook', + /const lifecycle = await startServeDelegation\(\{\s*host,\s*port,\s*dependencies:\s*\{\s*prepareBridgeAuth: \(\) => \{\s*if \(!readBridgeAuthState\(\)\) rotateBridgeSessionSecret\(\);\s*\},\s*\},\s*\}\);/.test(indexSource), + 'serve startup mints a bridge secret only when none exists, and only through the post-bind lifecycle hook', ); const supervisorSource = fs.readFileSync( path.join(repoRoot, 'mcp', 'src', 'agent-providers', 'spawn-supervisor.ts'), diff --git a/tests/mcp-wire-verb-routing.test.js b/tests/mcp-wire-verb-routing.test.js new file mode 100644 index 000000000..1cda5c5ae --- /dev/null +++ b/tests/mcp-wire-verb-routing.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const definitions = require('../extension/ai/tool-definitions.js'); +const bridgeSource = fs.readFileSync(path.join(__dirname, '../extension/ws/mcp-bridge-client.js'), 'utf8'); +const start = bridgeSource.indexOf(' async _handleExecuteAction(payload) {'); +const end = bridgeSource.indexOf('\n /**\n * Handle background-routed tools directly', start); +assert(start >= 0 && end > start); +const method = bridgeSource.slice(start, end); + +test('every manual wire verb resolves to its public registry definition', () => { + for (const tool of definitions.TOOL_REGISTRY.filter((entry) => entry._readOnly === false)) { + const verb = tool._contentVerb || tool._cdpVerb || tool.name; + assert.equal(definitions.getToolByNameOrVerb(tool.name), tool, tool.name); + assert.equal(definitions.getToolByNameOrVerb(verb), tool, `${tool.name} via ${verb}`); + } + assert.equal(definitions.getToolByNameOrVerb('cdpDoesNotExist'), null); +}); + +test('real bridge action method dispatches CDP verbs and rejects unknown verbs', async () => { + const calls = []; + const context = { + getToolByNameOrVerb: definitions.getToolByNameOrVerb, + MCP_DISPATCHER_SYNTHETIC_CHANGE_REPORT_TOOLS: new Set(), + wrapWithChangeReport: async ({ execute }) => execute(), + executeCDPToolDirect: async (request, tabId) => { + calls.push({ request, tabId }); + return { success: true }; + }, + resolveAgentTabOrError: async () => ({ success: true, tabId: 42 }), + }; + context.globalThis = context; + const handler = vm.runInNewContext(`({${method}})._handleExecuteAction`, context); + const client = { + _recordVisualSessionTickIfPresent: async () => {}, + _resolveMcpSessionRecordTarget: async () => null, + _recordMcpSessionAction() {}, + _clearVisualSessionIfFinal: async () => {}, + _sendToContentScript: async () => { throw new Error('CDP verb went to content script'); }, + }; + + assert.equal((await handler.call(client, { tool: 'cdpInsertText', params: { text: 'hello' } })).success, true); + assert.equal(calls[0].request.tool, 'cdpInsertText'); + assert.equal(calls[0].tabId, 42); + assert.equal((await handler.call(client, { tool: 'cdpDoubleClickAt', params: { x: 1, y: 2 } })).success, true); + assert.equal(calls[1].request.tool, 'cdpDoubleClickAt'); + const unknown = await handler.call(client, { tool: 'cdpDoesNotExist', params: {} }); + assert.equal(unknown.errorCode, 'mcp_route_unavailable'); + assert.equal(calls.length, 2); +}); diff --git a/tests/onboarding-agent-provider-clicks.test.js b/tests/onboarding-agent-provider-clicks.test.js index 36fbe25fc..a9ec7a0a7 100644 --- a/tests/onboarding-agent-provider-clicks.test.js +++ b/tests/onboarding-agent-provider-clicks.test.js @@ -164,7 +164,7 @@ function createHarness(initialStore, options) { ); assert.strictEqual( sha256(ONBOARDING_CSS_PATH), - '39f107705b7aae9fda75cdb1c009979433e5556042459621ec3f897ba4cb8707', + '6aa97ce28241b88c8939b88d1b8f3875ba7ba89d989712e81a72d8c4de010987', 'onboarding.css has no Phase 57 change' ); diff --git a/tests/recipe-schema-lock.test.js b/tests/recipe-schema-lock.test.js index 967d0c455..333c94c16 100644 --- a/tests/recipe-schema-lock.test.js +++ b/tests/recipe-schema-lock.test.js @@ -42,7 +42,7 @@ const TOOL_DEFS_PATH = path.join(REPO_ROOT, 'mcp', 'ai', 'tool-definitions.cjs') // tests/tool-definitions-parity.test.js:52 / capability-mcp-surface.test.js. The // recipe-rot work must NOT move this (no tool-definitions edit this phase). const EXPECTED_NON_TRIGGER_REGISTRY_HASH = - 'b9c30a5a61fbcdae60b851aebfea90d0961cb95d95c3c2adbf8357014bbbd7b9'; + '5b9731c2282461ff85a4e5ac5bab18ca6d4eae55f8695cdcaf0fb8d457322c29'; // The four trigger tools sit IN TOOL_REGISTRY but are excluded from the frozen // non-trigger baseline (mirrors tool-definitions-parity.test.js:35/132). diff --git a/tests/recovery-tools-bootstrap.test.js b/tests/recovery-tools-bootstrap.test.js index 8cc9ae71f..27a922000 100644 --- a/tests/recovery-tools-bootstrap.test.js +++ b/tests/recovery-tools-bootstrap.test.js @@ -52,7 +52,7 @@ function loadBridgeHarness() { return { success: true, tool: args.tool, params: args.params }; }, hasMcpToolRoute: () => true, - getToolByName: (name) => ({ name, _route: 'background', _emitChangeReport: true }), + getToolByNameOrVerb: (name) => ({ name, _route: 'background', _emitChangeReport: true }), wrapWithChangeReport: async (args) => { wrappedChangeReports.push(args); return args.execute(); diff --git a/tests/run-task-resolve-discipline.test.js b/tests/run-task-resolve-discipline.test.js index a499934a5..79aec16cc 100644 --- a/tests/run-task-resolve-discipline.test.js +++ b/tests/run-task-resolve-discipline.test.js @@ -19,6 +19,7 @@ * 4. sw_wake_settles_with_sw_evicted * 5. no_double_resolve_under_race * 6. heartbeat_ticker_cleared_on_safety_net + * 7. sw_evicted_reports_disconnect_reason * * Run: node tests/run-task-resolve-discipline.test.js */ @@ -510,6 +511,7 @@ async function runTest(name, fn) { assert(parsed && typeof parsed === 'object', 'content[0].text JSON-parses'); if (parsed) { assertEqual(parsed.sw_evicted, true, 'parsed.sw_evicted === true'); + assertEqual(parsed.disconnect_reason, 'bridge_disconnected', 'a cause-free disconnect reports bridge_disconnected'); assert(parsed.partial_state && parsed.partial_state.task_id === agentId, 'parsed.partial_state.task_id matches agentId'); assert(parsed.last_heartbeat_at === 12345 || parsed.last_heartbeat_at === seededSnapshot.last_heartbeat_at, @@ -602,6 +604,63 @@ async function runTest(name, fn) { assertEqual(harness.clock.activeIntervalCount(), 0, '0 active intervals after safety-net settle'); }); + // ----------------------------------------------------------------------- + // Test 7: sw_evicted_reports_disconnect_reason + // ----------------------------------------------------------------------- + // sw_evicted stays true for every disconnect because the recovery is the + // same; the cause the bridge attached must still reach the caller, and a + // malformed one must never be echoed. + await runTest('sw_evicted_reports_disconnect_reason', async () => { + const buildPath = path.join(__dirname, '..', 'mcp', 'build', 'tools', 'autopilot.js'); + const { registerAutopilotTools } = require(buildPath); + + async function runTaskAfterDisconnect(disconnectError) { + let toolBody = null; + const serverStub = { + tool(name, _desc, _schema, handler) { + if (name === 'run_task') toolBody = handler; + }, + sendLoggingMessage() {} + }; + let sendCallCount = 0; + let bridgeIsConnected = true; + const bridgeStub = { + get isConnected() { return bridgeIsConnected; }, + async sendAndWait(msg) { + sendCallCount += 1; + if (sendCallCount === 1) { + setTimeout(() => { bridgeIsConnected = true; }, 0); + bridgeIsConnected = false; + throw disconnectError; + } + if (msg && msg.type === 'mcp:get-task-snapshot') { + return { success: true, snapshot: { task_id: 'agent_reason', status: 'in_progress', last_heartbeat_at: 1 } }; + } + throw new Error('unexpected sendAndWait: ' + (msg && msg.type)); + } + }; + registerAutopilotTools( + serverStub, + bridgeStub, + { async enqueue(_name, fn) { return fn(); } }, + { async ensure() { return 'agent_reason'; } }, + ); + const result = await toolBody({ task: 'a long task' }, { _meta: {} }); + return JSON.parse(result.content[0].text); + } + + const revoked = new Error('Extension disconnected'); + revoked.bridgeDisconnectReason = 'extension_auth_revoked'; + const revokedResult = await runTaskAfterDisconnect(revoked); + assertEqual(revokedResult.sw_evicted, true, 'a revocation still arms the sw_evicted recovery'); + assertEqual(revokedResult.disconnect_reason, 'extension_auth_revoked', 'a revocation is reported as one, not as an eviction'); + + const malformed = new Error('Extension disconnected'); + malformed.bridgeDisconnectReason = 'Not A Code!'; + const malformedResult = await runTaskAfterDisconnect(malformed); + assertEqual(malformedResult.disconnect_reason, 'bridge_disconnected', 'a malformed cause falls back to bridge_disconnected'); + }); + console.log('\n--- Phase 239 plan 03 run-task-resolve-discipline summary ---'); console.log(' passed:', passed); console.log(' failed:', failed); diff --git a/tests/selector-fail-closed.test.js b/tests/selector-fail-closed.test.js new file mode 100644 index 000000000..ff9295ec0 --- /dev/null +++ b/tests/selector-fail-closed.test.js @@ -0,0 +1,45 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const selectors = fs.readFileSync(path.join(__dirname, '../extension/content/selectors.js'), 'utf8'); +const domState = fs.readFileSync(path.join(__dirname, '../extension/content/dom-state.js'), 'utf8'); + +test('selector handling preserves :has and does not broaden unsupported syntax', () => { + const start = selectors.indexOf(' function sanitizeSelector('); + const end = selectors.indexOf(' /**\n * Resolve a compact ref', start); + assert.ok(start >= 0 && end > start); + const queried = []; + const match = { id: 'matching-button' }; + const context = { + FSB: { sessionId: 'test', elementCache: { get: () => null, set() {} } }, + logger: { warn() {}, debug() {} }, + document: { querySelector(selector) { + queried.push(selector); + if (selector.includes(':contains(')) throw new SyntaxError('unsupported'); + return selector === 'button:has(.ready)' ? match : null; + } } + }; + const helpers = vm.runInNewContext(`${selectors.slice(start, end)}\n({ sanitizeSelector, querySelectorWithShadow })`, context); + assert.equal(helpers.querySelectorWithShadow('button:has(.ready)'), match); + assert.equal(helpers.querySelectorWithShadow('button:contains("Send")'), null); + assert.deepEqual(queried, ['button:has(.ready)', 'button:contains("Send")']); +}); + +test('cached element is discarded when its selector condition changes', () => { + const start = domState.indexOf(' class ElementCache {'); + const end = domState.indexOf(' const elementCache =', start); + assert.ok(start >= 0 && end > start); + const ElementCache = vm.runInNewContext(`${domState.slice(start, end)}\nElementCache`, { WeakRef }); + const cache = new ElementCache(5); + let ready = true; + const element = { isConnected: true, matches: () => ready }; + cache.set('button:has(.ready)', element); + assert.equal(cache.get('button:has(.ready)'), element); + ready = false; + assert.equal(cache.get('button:has(.ready)'), null); +}); diff --git a/tests/text-editing-contract.test.js b/tests/text-editing-contract.test.js new file mode 100644 index 000000000..d58365b30 --- /dev/null +++ b/tests/text-editing-contract.test.js @@ -0,0 +1,266 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); +const { getToolByName } = require('../extension/ai/tool-definitions.js'); + +const background = fs.readFileSync(path.join(__dirname, '../extension/background.js'), 'utf8'); +const actions = fs.readFileSync(path.join(__dirname, '../extension/content/actions.js'), 'utf8'); + +test('text tools publish replacement, append, and position controls', () => { + assert.equal(getToolByName('type_text').inputSchema.properties.clear_first.type, 'boolean'); + assert.deepEqual(getToolByName('insert_text').inputSchema.properties.position.enum, + ['caret', 'end', 'replace_all']); + assert.equal(getToolByName('insert_text').inputSchema.properties.selector.type, 'string'); +}); + +test('CDP replacement selects once before one insertion', async () => { + const start = background.indexOf('async function dispatchCdpTextInsertion('); + const end = background.indexOf('\nasync function handleCDPInsertTextUnlocked', start); + const commands = []; + const context = { + prepareCdpTextTarget: async () => ({ success: true }), + chrome: { debugger: { sendCommand: async (_target, method, params) => commands.push({ method, params }) } }, + navigator: { platform: 'MacIntel', userAgent: 'Macintosh' } + }; + const dispatch = vm.runInNewContext(`${background.slice(start, end)}\ndispatchCdpTextInsertion`, context); + const result = await dispatch(42, 'new\ntext', 'replace_all', '#draft'); + assert.equal(result.success, true); + assert.deepEqual(commands.map(c => c.method), + ['Input.dispatchKeyEvent', 'Input.dispatchKeyEvent', 'Input.insertText']); + assert.equal(commands[0].params.windowsVirtualKeyCode, 65); + assert.equal(commands[0].params.commands[0], 'selectAll'); + assert.equal(commands[2].params.text, 'new\ntext'); + commands.length = 0; + await dispatch(42, ' more', 'end', '#draft'); + assert.deepEqual(commands.map(c => c.method), ['Input.insertText']); +}); + +test('editable target resolution rejects ambiguous wrappers', () => { + const start = actions.indexOf(' function resolveTextEntryTarget('); + const end = actions.indexOf('\n function readEditorText(', start); + const resolve = vm.runInNewContext(`${actions.slice(start, end)}\nresolveTextEntryTarget`); + const first = { tagName: 'TEXTAREA' }; + const second = { tagName: 'TEXTAREA' }; + assert.equal(resolve({ tagName: 'DIV', isContentEditable: false, + querySelectorAll: () => [first, second] }), null); + assert.equal(resolve({ tagName: 'DIV', isContentEditable: false, + querySelectorAll: () => [first] }), first); +}); + +function loadCdpTextInsertion(activeElement, commands) { + const start = background.indexOf('async function prepareCdpTextTarget('); + const end = background.indexOf('\nasync function handleCDPInsertTextUnlocked', start); + const context = { + document: { activeElement }, + chrome: { + scripting: { executeScript: async ({ func, args }) => [{ result: func(...args) }] }, + debugger: { sendCommand: async (_target, method, params) => commands.push({ method, params }) } + }, + navigator: { platform: 'MacIntel', userAgent: 'Macintosh' } + }; + return vm.runInNewContext(`${background.slice(start, end)}\ndispatchCdpTextInsertion`, context); +} + +test('CDP replacement reaches a canvas editor focused inside a nested frame', async () => { + const commands = []; + const dispatch = loadCdpTextInsertion({ tagName: 'IFRAME', querySelectorAll: () => [] }, commands); + const result = await dispatch(42, 'replacement', 'replace_all', null); + assert.equal(result.success, true); + assert.deepEqual(commands.map(c => c.method), + ['Input.dispatchKeyEvent', 'Input.dispatchKeyEvent', 'Input.insertText']); + assert.equal(commands[2].params.text, 'replacement'); +}); + +test('CDP replacement still refuses a focused element that is not editable', async () => { + const commands = []; + const dispatch = loadCdpTextInsertion( + { tagName: 'DIV', isContentEditable: false, querySelectorAll: () => [] }, commands); + const result = await dispatch(42, 'replacement', 'replace_all', null); + assert.equal(result.success, false); + assert.deepEqual(commands, []); +}); + +test('a CDP insertion that is refused before dispatch releases the debugger', async () => { + const start = background.indexOf('async function handleCDPInsertTextUnlocked('); + const end = background.indexOf('\n/**\n * Handle CDP-based mouse click', start); + const detached = []; + const responses = []; + const refusal = { success: false, error: 'Target is not editable' }; + const context = { + attachFsbDebugger: async () => {}, + dispatchCdpTextInsertion: async () => refusal, + automationLogger: { logActionExecution() {}, debug() {} }, + cdpFailureResult: (error) => ({ success: false, error: error.message }), + chrome: { debugger: { detach: async (target) => detached.push(target) } } + }; + const handler = vm.runInNewContext(`${background.slice(start, end)}\nhandleCDPInsertTextUnlocked`, context); + await handler({ text: 'replacement', clearFirst: true }, { tab: { id: 42 } }, (response) => responses.push(response)); + assert.deepEqual(JSON.parse(JSON.stringify(detached)), [{ tabId: 42 }]); + assert.deepEqual(responses, [refusal]); +}); + +function busyDebuggerError() { + const error = new Error('The debugger for tab 42 is busy. Retry the operation.'); + error.code = 'SCREENSHOT_DEBUGGER_BUSY'; + error.retryable = true; + return error; +} + +const cdpFailureResultStub = (error, extra) => Object.assign({ + success: false, error: error.message, code: error.code, retryable: Boolean(error.retryable) +}, extra || {}); + +function loadInsertHandler(overrides) { + const start = background.indexOf('async function handleCDPInsertTextUnlocked('); + const end = background.indexOf('\n/**\n * Handle CDP-based mouse click', start); + const context = { + attachFsbDebugger: async () => {}, + dispatchCdpTextInsertion: async () => ({ success: true }), + automationLogger: { logActionExecution() {}, debug() {} }, + cdpFailureResult: cdpFailureResultStub, + chrome: { debugger: { detach: async () => {} } }, + ...overrides + }; + return vm.runInNewContext(`${background.slice(start, end)}\nhandleCDPInsertTextUnlocked`, context); +} + +test('a CDP insertion that cannot attach the debugger stays retryable', async () => { + let dispatched = 0; + const detached = []; + const responses = []; + const handler = loadInsertHandler({ + attachFsbDebugger: async () => { throw busyDebuggerError(); }, + dispatchCdpTextInsertion: async () => { dispatched++; return { success: true }; }, + chrome: { debugger: { detach: async (target) => detached.push(target) } } + }); + await handler({ text: 'replacement' }, { tab: { id: 42 } }, (response) => responses.push(response)); + assert.equal(responses.length, 1); + assert.equal(responses[0].code, 'SCREENSHOT_DEBUGGER_BUSY'); + assert.equal(responses[0].retryable, true); + assert.equal(responses[0].mayHaveExecuted, undefined); + assert.equal(dispatched, 0); + assert.equal(detached.length, 0); +}); + +test('a CDP insertion that fails after input began stays uncertain', async () => { + const responses = []; + const handler = loadInsertHandler({ + dispatchCdpTextInsertion: async () => { + const error = new Error('Detached while handling command.'); + error.mayHaveExecuted = true; + throw error; + } + }); + await handler({ text: 'replacement' }, { tab: { id: 42 } }, (response) => responses.push(response)); + assert.equal(responses[0].outcome, 'unknown'); + assert.equal(responses[0].mayHaveExecuted, true); + assert.equal(responses[0].retryable, false); +}); + +test('CDP text dispatch marks only failures after input as possibly executed', async () => { + const start = background.indexOf('async function dispatchCdpTextInsertion('); + const end = background.indexOf('\nasync function handleCDPInsertTextUnlocked', start); + const load = (context) => vm.runInNewContext(`${background.slice(start, end)}\ndispatchCdpTextInsertion`, { + navigator: { platform: 'MacIntel', userAgent: 'Macintosh' }, ...context + }); + const afterInput = load({ + prepareCdpTextTarget: async () => ({ success: true }), + chrome: { debugger: { sendCommand: async (_target, method) => { + if (method === 'Input.insertText') throw new Error('Detached while handling command.'); + } } } + }); + await assert.rejects(afterInput(42, 'text', 'replace_all', '#draft'), (error) => error.mayHaveExecuted === true); + let commands = 0; + const beforeInput = load({ + prepareCdpTextTarget: async () => { throw new Error('Cannot access contents of the page.'); }, + chrome: { debugger: { sendCommand: async () => { commands++; } } } + }); + await assert.rejects(beforeInput(42, 'text', 'caret', '#draft'), (error) => error.mayHaveExecuted === undefined); + assert.equal(commands, 0); +}); + +test('a direct CDP insertion that cannot attach the debugger stays retryable', async () => { + const start = background.indexOf('async function executeCDPToolDirectUnlocked('); + const end = background.indexOf('\nasync function handleMonacoEditorInsert', start); + let dispatched = 0; + const context = { + attachFsbDebugger: async () => { throw busyDebuggerError(); }, + dispatchCdpTextInsertion: async () => { dispatched++; return { success: true }; }, + automationLogger: { logActionExecution() {}, debug() {} }, + cdpFailureResult: cdpFailureResultStub, + chrome: { debugger: { detach: async () => {} } } + }; + const execute = vm.runInNewContext(`${background.slice(start, end)}\nexecuteCDPToolDirectUnlocked`, context); + const result = await execute({ tool: 'cdpInsertText', params: { text: 'replacement' } }, 42); + assert.equal(result.code, 'SCREENSHOT_DEBUGGER_BUSY'); + assert.equal(result.retryable, true); + assert.equal(result.mayHaveExecuted, undefined); + assert.equal(dispatched, 0); +}); + +const messaging = fs.readFileSync(path.join(__dirname, '../extension/content/messaging.js'), 'utf8'); + +// lengths: Docs paragraph text length per measurement (before, then after); null means no paragraph elements. +function loadClipboardPaste({ clipboardWrite = async () => {}, lengths = [0], keyReply = { success: true } } = {}) { + const start = messaging.indexOf(' async function clipboardPasteHTML('); + const end = messaging.indexOf('\n /**', start); + const sent = []; + let measurements = 0; + const context = { + Blob: class {}, + ClipboardItem: class {}, + navigator: { platform: 'MacIntel', userAgent: 'Macintosh', clipboard: { write: clipboardWrite } }, + document: { querySelectorAll: () => { + const length = lengths[Math.min(measurements++, lengths.length - 1)]; + return length === null ? [] : [{ textContent: 'x'.repeat(length) }]; + } }, + chrome: { runtime: { lastError: null, sendMessage: (message, reply) => { sent.push(message); reply(keyReply); } } }, + logger: { warn() {}, debug() {} }, + setTimeout: (fn) => fn() + }; + const paste = vm.runInNewContext(`${messaging.slice(start, end)}\nclipboardPasteHTML`, context); + return { paste: () => paste('

hi

', 'hi'), sent, measurements: () => measurements }; +} + +test('a formatted paste whose clipboard write fails reports that nothing was inserted', async () => { + const harness = loadClipboardPaste({ clipboardWrite: async () => { throw new Error('Document is not focused.'); } }); + const result = await harness.paste(); + assert.equal(result.success, false); + assert.equal(result.nothingInserted, true); + assert.equal(harness.sent.length, 0); +}); + +test('a formatted paste that leaves measurable Docs text unchanged reports that nothing was inserted', async () => { + const harness = loadClipboardPaste({ lengths: [5] }); + const result = await harness.paste(); + assert.equal(result.success, false); + assert.equal(result.nothingInserted, true); + assert.equal(harness.sent.length, 1); + assert.equal(harness.measurements(), 5); +}); + +test('a formatted paste stays uncertain when Docs text cannot be measured or changed oddly', async () => { + assert.equal((await loadClipboardPaste({ lengths: [null] }).paste()).nothingInserted, false); + assert.equal((await loadClipboardPaste({ lengths: [5, 3] }).paste()).nothingInserted, false); + const keyFailed = await loadClipboardPaste({ lengths: [5], keyReply: { success: false, error: 'Detached' } }).paste(); + assert.equal(keyFailed.success, false); + assert.equal(keyFailed.nothingInserted, false); +}); + +test('a formatted paste that lands late still counts as inserted', async () => { + const result = await loadClipboardPaste({ lengths: [5, 5, 5, 9] }).paste(); + assert.equal(result.success, true); + assert.equal(result.textLenAfter, 9); +}); + +test('Docs formatted paste falls back to plain insertion only when nothing was inserted', () => { + const start = actions.indexOf('// --- FORMATTED PASTE PATH ---'); + const end = actions.indexOf('// --- END FORMATTED PASTE PATH ---', start); + const block = actions.slice(start, end); + assert.match(block, /if \(!pasteResult\.nothingInserted\) \{\s*return \{ success: false, outcome: 'unknown', mayHaveExecuted: true,/); + assert.equal(block.split("outcome: 'unknown'").length - 1, 2); +}); diff --git a/tests/tool-definitions-parity.test.js b/tests/tool-definitions-parity.test.js index 1d0401249..4305c76ff 100644 --- a/tests/tool-definitions-parity.test.js +++ b/tests/tool-definitions-parity.test.js @@ -49,7 +49,7 @@ const EXPECTED_NON_TRIGGER_TOOL_NAMES = [ 'get_site_guide', 'search_memory', 'report_progress', 'complete_task', 'partial_task', 'fail_task', 'upload_file' ]; -const EXPECTED_NON_TRIGGER_REGISTRY_HASH = 'b9c30a5a61fbcdae60b851aebfea90d0961cb95d95c3c2adbf8357014bbbd7b9'; +const EXPECTED_NON_TRIGGER_REGISTRY_HASH = '5b9731c2282461ff85a4e5ac5bab18ca6d4eae55f8695cdcaf0fb8d457322c29'; function stable(value) { if (Array.isArray(value)) return value.map(stable); diff --git a/tests/trigger-blocking-reporting.test.js b/tests/trigger-blocking-reporting.test.js index 6bcc70886..bdfb3d1f0 100644 --- a/tests/trigger-blocking-reporting.test.js +++ b/tests/trigger-blocking-reporting.test.js @@ -571,6 +571,49 @@ async function bridge_disconnect_partial() { check(parsed && parsed.trigger_id === 'trg_disconnect', 'bridge disconnect result keeps trigger_id'); check(parsed && parsed.outcome === 'detached', 'bridge disconnect armed state maps to detached outcome'); check(parsed && parsed.partial_state && parsed.partial_state.status === 'armed', 'bridge disconnect returns partial_state snapshot'); + check(parsed && parsed.disconnect_reason === 'bridge_disconnected', 'a cause-free disconnect reports bridge_disconnected'); +} + +async function bridge_disconnect_reports_cause() { + console.log('\n--- bridge_disconnect_reports_cause ---'); + const triggersModule = await loadBuildModule(path.join('tools', 'triggers.js')); + const agentScope = await loadAgentScope(); + + async function triggerAfterDisconnect(reason) { + const harness = createToolHarness({ + onSendAndWait: async (message) => { + if (message.type === 'mcp:trigger') { + const error = new Error('Extension disconnected'); + error.bridgeDisconnectReason = reason; + throw error; + } + if (message.type === 'mcp:get-trigger-status') { + return { + success: true, + trigger_id: message.payload.trigger_id, + status: 'armed', + watch: 'live-observe', + }; + } + return { success: true }; + }, + }); + triggersModule.registerTriggerTools(harness.server, harness.bridge, harness.queue, agentScope); + const result = await harness.getHandler('trigger')({ + trigger_id: 'trg_disconnect_cause', + selector: '#price', + condition: { kind: 'changed' }, + watch: 'live-observe', + }, harness.createExtra()); + return readJsonTextResult(result); + } + + const reaped = await triggerAfterDisconnect('extension_reaped_pong_timeout'); + check(reaped && reaped.sw_evicted === true, 'a reaped socket still arms the sw_evicted recovery'); + check(reaped && reaped.disconnect_reason === 'extension_reaped_pong_timeout', 'the bridge-attached cause reaches the caller'); + + const malformed = await triggerAfterDisconnect('Not A Code!'); + check(malformed && malformed.disconnect_reason === 'bridge_disconnected', 'a malformed cause falls back to bridge_disconnected'); } async function run() { @@ -582,6 +625,7 @@ async function run() { await blocking_timeout_marks_timed_out(); await blocking_rearm_fire_resolves_still_armed(); await bridge_disconnect_partial(); + await bridge_disconnect_reports_cause(); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/twitter-guide-safety.test.js b/tests/twitter-guide-safety.test.js new file mode 100644 index 000000000..f9b86887c --- /dev/null +++ b/tests/twitter-guide-safety.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../extension/site-guides/social/twitter.js'), 'utf8'); +let guide; +vm.runInNewContext(source, { registerSiteGuide(value) { guide = value; } }); + +test('X guide only matches real X and Twitter hosts', () => { + for (const url of ['https://x.com/user', 'https://www.x.com/user', 'https://mobile.twitter.com/user']) { + assert.equal(guide.patterns.some(pattern => pattern.test(url)), true, url); + } + for (const url of ['https://x.com.evil.test', 'https://notx.com', 'https://evil.test/x.com', + 'https://twitter.com.attacker.org', 'https://example.org/?next=https://x.com']) { + assert.equal(guide.patterns.some(pattern => pattern.test(url)), false, url); + } +}); + +test('task keyword fallback cannot apply the X guide to a lookalike host', () => { + const index = fs.readFileSync(path.join(__dirname, '../extension/site-guides/index.js'), 'utf8'); + const context = {}; + vm.runInNewContext(`${index}\n${source}\nthis.lookup = getGuideForTask;`, context); + assert.equal(context.lookup('tweet post reply', 'https://x.com.evil.test/thread'), null); + assert.equal(context.lookup('tweet post reply', 'https://x.com/thread').site, 'Twitter/X'); +}); + +test('compose guidance names both button contexts and account/reply checks', () => { + assert.equal(guide.selectors.tweetButton, '[data-testid="tweetButtonInline"]'); + assert.equal(guide.selectors.modalTweetButton, '[data-testid="tweetButton"]'); + assert.match(guide.workflows.createPost.join(' '), /AccountSwitcher/); + assert.match(guide.workflows.replyToPost.join(' '), /permalink/); + assert.match(guide.workflows.replyToPost.join(' '), /only once/); +}); + +test('autopilot forwards bounded guide workflows and warnings', () => { + const agentLoop = fs.readFileSync(path.join(__dirname, '../extension/ai/agent-loop.js'), 'utf8'); + const start = agentLoop.indexOf("} else if (call.name === 'get_site_guide') {"); + const end = agentLoop.indexOf("} else if (call.name === 'complete_task') {", start); + const branch = agentLoop.slice(start, end); + assert.match(branch, /replyToPost: guide\.workflows/); + assert.match(branch, /guide\.warnings\.slice\(0, 6\)/); + assert.match(branch, /\.slice\(0, 5000\)/); +}); diff --git a/tests/vault-list-state.test.js b/tests/vault-list-state.test.js new file mode 100644 index 000000000..3b295bb2e --- /dev/null +++ b/tests/vault-list-state.test.js @@ -0,0 +1,36 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../extension/ws/mcp-bridge-client.js'), 'utf8'); + +for (const [methodName, statusAction, listAction, field] of [ + ['_handleListCredentials', 'getCredentialVaultStatus', 'getAllCredentials', 'credentials'], + ['_handleListPayments', 'getPaymentVaultStatus', 'getAllPaymentMethods', 'paymentMethods'] +]) { + const start = source.indexOf(` async ${methodName}() {`); + const end = source.indexOf('\n async ', start + 10); + const method = vm.runInNewContext(`({${source.slice(start, end)}}).${methodName}`); + test(`${methodName} distinguishes unconfigured, locked, and empty`, async () => { + const calls = []; + const client = { _dispatchToBackground: async ({ action }) => { + calls.push(action); + if (action === statusAction) return client.status; + if (action === listAction) return { success: true, [field]: [] }; + throw new Error(action); + } }; + client.status = { configured: false, unlocked: false }; + assert.equal((await method.call(client)).errorCode, 'vault_not_configured'); + client.status = { configured: true, unlocked: false }; + assert.equal((await method.call(client)).errorCode, 'vault_locked'); + client.status = { configured: true, unlocked: true, paymentUnlocked: true }; + const empty = await method.call(client); + assert.equal(empty.success, true); + assert.deepEqual(Array.from(empty[field]), []); + assert.deepEqual(calls, [statusAction, statusAction, statusAction, listAction]); + }); +}