diff --git a/packages/@n8n/mcp-browser-extension/manifest.json b/packages/@n8n/mcp-browser-extension/manifest.json index 7da055dcd6d..79a7757f9a5 100644 --- a/packages/@n8n/mcp-browser-extension/manifest.json +++ b/packages/@n8n/mcp-browser-extension/manifest.json @@ -2,10 +2,20 @@ "manifest_version": 3, "name": "n8n Browser Use", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvRoWEdJjhgP9qs6R1jcemywrw+I91EJZtYur5C97hjjUc5nPao4jSv1qXFksdKuMddb9IEvzBElr5EYsXSaiVqdbRl8Gge0xYV1gGga653T2d9BuXL7NKv/wZxJ2i/coHSjhhIULQUBAVwu0JFMbHY5T8LfqrzBljuY7u1Xa7jmLmx0QrsoKLbGUoOBVZz4ztEGKEQHEelgg+ph2LrcYJczMBZ80PaHQAaWrvbCYF4vnZLd++Svy70ZCt7gr93L8BXHc8j1c3VojQTk+Uvqhm/4nZdYHlEmruQkd0pE+zTyegbcDlw0oc+6sLbsc0CqmJz8zH0OvSTfSVK7h6LNwbQIDAQAB", - "version": "0.0.5", + "version": "0.0.6", "description": "Let n8n AI set up credentials, fill out forms, and automate anything in your browser", "permissions": ["debugger", "activeTab", "tabs", "storage", "webNavigation"], "host_permissions": [""], + "externally_connectable": { + "matches": [ + "https://*.app.n8n.cloud/*", + "https://*.stage-app.n8n.cloud/*", + "http://localhost/*", + "https://localhost/*", + "http://127.0.0.1/*", + "https://127.0.0.1/*" + ] + }, "web_accessible_resources": [ { "resources": ["connect.html"], diff --git a/packages/@n8n/mcp-browser-extension/package.json b/packages/@n8n/mcp-browser-extension/package.json index d80bf2ec63b..6b778a6babc 100644 --- a/packages/@n8n/mcp-browser-extension/package.json +++ b/packages/@n8n/mcp-browser-extension/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/mcp-browser-extension", - "version": "0.0.5", + "version": "0.0.6", "private": true, "description": "Chrome extension that lets n8n AI control browser tabs via CDP", "scripts": { diff --git a/packages/@n8n/mcp-browser-extension/src/background.test.ts b/packages/@n8n/mcp-browser-extension/src/background.test.ts index 0ef86aec570..75432bc4a93 100644 --- a/packages/@n8n/mcp-browser-extension/src/background.test.ts +++ b/packages/@n8n/mcp-browser-extension/src/background.test.ts @@ -6,17 +6,27 @@ // --------------------------------------------------------------------------- type TabUpdatedHandler = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => void; +type ExternalMessageHandler = ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response: unknown) => void, +) => unknown; const EXT_ORIGIN = 'chrome-extension://testextensionid/'; const CONNECT_URL = `${EXT_ORIGIN}connect.html`; const tabUpdatedListeners: TabUpdatedHandler[] = []; +const tabRemovedListeners: Array<(tabId: number) => void> = []; +const externalMessageListeners: ExternalMessageHandler[] = []; const chromeMock = { runtime: { getURL: vi.fn((path: string) => `${EXT_ORIGIN}${path}`), sendMessage: vi.fn().mockResolvedValue(undefined), onMessage: { addListener: vi.fn() }, + onMessageExternal: { + addListener: vi.fn((fn: ExternalMessageHandler) => externalMessageListeners.push(fn)), + }, }, tabs: { query: vi.fn().mockResolvedValue([]), @@ -24,12 +34,18 @@ const chromeMock = { remove: vi.fn().mockResolvedValue(undefined), reload: vi.fn().mockResolvedValue(undefined), onCreated: { addListener: vi.fn() }, - onRemoved: { addListener: vi.fn() }, + onRemoved: { + addListener: vi.fn((fn: (tabId: number) => void) => tabRemovedListeners.push(fn)), + }, onUpdated: { addListener: vi.fn((fn: TabUpdatedHandler) => tabUpdatedListeners.push(fn)), }, }, - windows: { update: vi.fn().mockResolvedValue(undefined) }, + windows: { + update: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + getLastFocused: vi.fn().mockResolvedValue({ left: 0, top: 0, width: 1920, height: 1080 }), + }, storage: { session: { set: vi.fn().mockResolvedValue(undefined), @@ -61,6 +77,26 @@ function simulateTabUpdated(tabId: number, url: string): void { for (const fn of tabUpdatedListeners) fn(tabId, { url } as chrome.tabs.TabChangeInfo); } +/** + * Invoke the registered external-message listeners. Connect responses are held + * open until the flow settles, so the result is exposed as a getter that + * reflects late resolutions too. + */ +async function simulateExternalMessage(message: unknown, origin: string): Promise<() => unknown> { + const holder: { value?: unknown } = {}; + for (const fn of externalMessageListeners) { + fn(message, { origin } as chrome.runtime.MessageSender, (r) => (holder.value = r)); + } + await flush(); + return () => holder.value; +} + +/** Invoke the registered tab-removed listeners as Chrome would. */ +async function simulateTabRemoved(tabId: number): Promise { + for (const fn of tabRemovedListeners) fn(tabId); + await flush(); +} + /** Flush pending microtasks/macrotasks so the listener's async IIFE settles. */ const flush = async () => await new Promise((resolve) => setTimeout(resolve, 0)); @@ -148,3 +184,162 @@ describe('connect.html tab deduplication', () => { expect(chromeMock.tabs.query).not.toHaveBeenCalled(); }); }); + +describe('external messages (direct connect flow)', () => { + const ALLOWED_ORIGIN = 'https://acme.app.n8n.cloud'; + const RELAY_URL = 'wss://acme.app.n8n.cloud/browser-use/extension/abc?token=bu_x'; + const POPUP_TAB_ID = 99; + + // The throttle keys off Date.now(); advance it past the window before each + // test. Re-applied per test because the config restores mocks after each one. + let nowMs = 0; + beforeEach(() => { + nowMs += 60_000; + vi.spyOn(Date, 'now').mockImplementation(() => nowMs); + chromeMock.tabs.query.mockResolvedValue([]); + chromeMock.windows.create.mockResolvedValue({ tabs: [{ id: POPUP_TAB_ID }] }); + chromeMock.windows.getLastFocused.mockResolvedValue({ + left: 0, + top: 0, + width: 1920, + height: 1080, + }); + }); + + it('ignores messages from disallowed origins', async () => { + const response = await simulateExternalMessage( + { type: 'connect', relayUrl: RELAY_URL }, + 'https://evil.example.com', + ); + expect(response()).toBeUndefined(); + expect(chromeMock.windows.create).not.toHaveBeenCalled(); + }); + + it('ignores malformed messages', async () => { + const response = await simulateExternalMessage({ type: 'connect' }, ALLOWED_ORIGIN); + expect(response()).toBeUndefined(); + expect(chromeMock.windows.create).not.toHaveBeenCalled(); + }); + + it('opens a centered popup window and accepts the request', async () => { + const response = await simulateExternalMessage( + { type: 'connect', relayUrl: RELAY_URL }, + ALLOWED_ORIGIN, + ); + + expect(chromeMock.storage.session.set).toHaveBeenCalledWith({ pendingRelayUrl: RELAY_URL }); + expect(chromeMock.windows.create).toHaveBeenCalledWith({ + url: connectUrlWithRelay(RELAY_URL), + type: 'popup', + width: 620, + height: 640, + left: 650, + top: 220, + }); + expect(response()).toEqual({ accepted: true }); + }); + + it('reuses an already-open connect page instead of opening a new popup', async () => { + chromeMock.tabs.query.mockResolvedValue([ + { id: 5, windowId: 50, url: connectUrlWithRelay('ws://localhost:1111') }, + ]); + + const response = await simulateExternalMessage( + { type: 'connect', relayUrl: RELAY_URL }, + ALLOWED_ORIGIN, + ); + + expect(chromeMock.windows.create).not.toHaveBeenCalled(); + expect(chromeMock.tabs.update).toHaveBeenCalledWith(5, { active: true }); + expect(chromeMock.windows.update).toHaveBeenCalledWith(50, { focused: true }); + expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith({ + type: 'relayUrlReady', + relayUrl: RELAY_URL, + }); + expect(response()).toEqual({ accepted: true }); + }); + + it('rejects a relay URL that is not a recognized n8n instance', async () => { + const response = await simulateExternalMessage( + { type: 'connect', relayUrl: 'wss://evil.example.com/relay' }, + ALLOWED_ORIGIN, + ); + + expect(response()).toEqual({ accepted: false }); + expect(chromeMock.windows.create).not.toHaveBeenCalled(); + expect(chromeMock.storage.session.set).not.toHaveBeenCalled(); + }); + + it('responds not accepted when opening the popup fails', async () => { + chromeMock.windows.create.mockRejectedValue(new Error('window creation failed')); + + const response = await simulateExternalMessage( + { type: 'connect', relayUrl: RELAY_URL }, + ALLOWED_ORIGIN, + ); + + expect(response()).toEqual({ accepted: false }); + }); + + it('throttles rapid connect requests', async () => { + const first = await simulateExternalMessage( + { type: 'connect', relayUrl: RELAY_URL }, + ALLOWED_ORIGIN, + ); + nowMs += 500; + const second = await simulateExternalMessage( + { type: 'connect', relayUrl: RELAY_URL }, + ALLOWED_ORIGIN, + ); + + expect(first()).toEqual({ accepted: true }); + expect(second()).toEqual({ accepted: false }); + expect(chromeMock.windows.create).toHaveBeenCalledTimes(1); + }); + + it('holds the connect result open until the connect page is closed', async () => { + await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN); + const result = await simulateExternalMessage( + { type: 'connectResult', relayUrl: RELAY_URL }, + ALLOWED_ORIGIN, + ); + expect(result()).toBeUndefined(); + + await simulateTabRemoved(POPUP_TAB_ID); + + expect(result()).toEqual({ connected: false }); + }); + + it('ignores unrelated tab closures while a flow is pending', async () => { + await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN); + const result = await simulateExternalMessage( + { type: 'connectResult', relayUrl: RELAY_URL }, + ALLOWED_ORIGIN, + ); + + await simulateTabRemoved(123); + + expect(result()).toBeUndefined(); + }); + + it('answers a connect result for an unknown flow immediately', async () => { + const result = await simulateExternalMessage( + { type: 'connectResult', relayUrl: 'wss://acme.app.n8n.cloud/other' }, + ALLOWED_ORIGIN, + ); + + expect(result()).toEqual({ connected: false }); + }); + + it('resolves a superseded flow when a newer connect request arrives', async () => { + await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN); + const firstResult = await simulateExternalMessage( + { type: 'connectResult', relayUrl: RELAY_URL }, + ALLOWED_ORIGIN, + ); + nowMs += 60_000; + await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN); + + expect(firstResult()).toEqual({ connected: false }); + }); +}); diff --git a/packages/@n8n/mcp-browser-extension/src/background.ts b/packages/@n8n/mcp-browser-extension/src/background.ts index 05d4bd71a09..6d0dcac3f5f 100644 --- a/packages/@n8n/mcp-browser-extension/src/background.ts +++ b/packages/@n8n/mcp-browser-extension/src/background.ts @@ -6,14 +6,21 @@ */ import { createLogger } from './logger'; -import { isAllowedRelayUrl } from './relayAllowlist'; +import { isAllowedPageOrigin, isAllowedRelayUrl } from './relayAllowlist'; import { RelayConnection, isEligibleTab } from './relayConnection'; -import type { ExtensionMessage, TabManagementSettings } from './types'; +import type { + ExtensionMessage, + ExternalConnectResponse, + ExternalConnectResultResponse, + TabManagementSettings, +} from './types'; +import { isExternalMessage } from './types'; const log = createLogger('bg'); interface ConnectionState { relay: RelayConnection; + relayUrl: string; } let activeConnection: ConnectionState | null = null; @@ -132,40 +139,162 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { log.debug('connect.html tab detected:', tabId, 'relayUrl:', relayUrl); void (async () => { - // A new relay URL means the server started a new session — disconnect any existing one - if (activeConnection) { - log.debug('new relay URL received while connected, disconnecting old session'); - disconnect(); - } - - // Store relay URL for the UI to pick up - await chrome.storage.session.set({ [RELAY_URL_KEY]: relayUrl }); - - // Check for an existing connect.html tab to reuse - const connectUrl = chrome.runtime.getURL(CONNECT_PAGE); - const allConnectTabs = await chrome.tabs.query({ url: `${connectUrl}*` }); - const existing = allConnectTabs.find((t) => t.id !== tabId && t.id !== undefined); - + const existing = await deliverRelayUrl(relayUrl, tabId); if (existing?.id !== undefined) { - // Reuse existing tab: focus it and close the duplicate - log.debug('reusing existing connect.html tab:', existing.id); - await chrome.tabs.update(existing.id, { active: true }); - if (existing.windowId !== undefined) { - await chrome.windows.update(existing.windowId, { focused: true }); - } await chrome.tabs.remove(tabId); - - // The existing tab stays loaded, so its listener is alive to apply the new relay URL. - try { - await chrome.runtime.sendMessage({ type: 'relayUrlReady', relayUrl }); - } catch { - // Defensive: the stored RELAY_URL_KEY covers a missed message on next mount. - } } // If no existing tab, let the new one load normally — App.vue reads relay URL from storage })(); }); +/** + * Stores a fresh relay URL and hands it to an already-open connect page if there + * is one (focused + notified via `relayUrlReady`). Returns the reused tab, if any. + */ +async function deliverRelayUrl( + relayUrl: string, + excludeTabId?: number, +): Promise { + // A new relay URL means the server started a new session — disconnect any existing one + if (activeConnection) { + log.debug('new relay URL received while connected, disconnecting old session'); + disconnect(); + } + + // Store relay URL for the UI to pick up + await chrome.storage.session.set({ [RELAY_URL_KEY]: relayUrl }); + + // Check for an existing connect.html tab to reuse + const connectUrl = chrome.runtime.getURL(CONNECT_PAGE); + const allConnectTabs = await chrome.tabs.query({ url: `${connectUrl}*` }); + const existing = allConnectTabs.find((t) => t.id !== excludeTabId && t.id !== undefined); + if (existing?.id === undefined) return undefined; + + log.debug('reusing existing connect.html tab:', existing.id); + await chrome.tabs.update(existing.id, { active: true }); + if (existing.windowId !== undefined) { + await chrome.windows.update(existing.windowId, { focused: true }); + } + + // The existing tab stays loaded, so its listener is alive to apply the new relay URL. + try { + await chrome.runtime.sendMessage({ type: 'relayUrlReady', relayUrl }); + } catch { + // Defensive: the stored RELAY_URL_KEY covers a missed message on next mount. + } + return existing; +} + +// --------------------------------------------------------------------------- +// External messages from n8n pages (externally_connectable) — the n8n UI +// requests a connection and the user confirms in an extension-owned popup. +// --------------------------------------------------------------------------- + +const EXTERNAL_CONNECT_THROTTLE_MS = 1000; +const CONNECT_POPUP_WIDTH = 620; +const CONNECT_POPUP_HEIGHT = 640; + +let lastExternalConnectAt = 0; + +interface PendingConnectFlow { + relayUrl: string; + tabId: number | null; + notify: ((response: ExternalConnectResultResponse) => void) | null; +} + +let pendingConnectFlow: PendingConnectFlow | null = null; + +function settleConnectFlow(connected: boolean): void { + if (!pendingConnectFlow) return; + log.debug('settling connect flow:', pendingConnectFlow.relayUrl, 'connected:', connected); + try { + pendingConnectFlow.notify?.({ connected }); + } finally { + pendingConnectFlow = null; + } +} + +chrome.runtime.onMessageExternal.addListener( + ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response: unknown) => void, + ) => { + if (!isExternalMessage(message)) return false; + if (!isAllowedPageOrigin(sender.origin)) { + log.warn('ignoring external message from disallowed origin:', sender.origin); + return false; + } + log.debug('external message received:', message.type, 'from', sender.origin); + + if (message.type === 'connect') { + void handleExternalConnect(message.relayUrl).then(sendResponse, (error: unknown) => { + log.warn('external connect failed:', error); + sendResponse({ accepted: false }); + }); + return true; + } + + if (activeConnection?.relayUrl === message.relayUrl) { + sendResponse({ connected: true }); + return false; + } + if (pendingConnectFlow?.relayUrl === message.relayUrl) { + pendingConnectFlow.notify = sendResponse; + return true; + } + sendResponse({ connected: false }); + return false; + }, +); + +async function handleExternalConnect(relayUrl: string): Promise { + if (!isAllowedRelayUrl(relayUrl)) { + log.warn('refusing external connect to disallowed relay:', relayUrl); + return { accepted: false }; + } + + const now = Date.now(); + if (now - lastExternalConnectAt < EXTERNAL_CONNECT_THROTTLE_MS) { + log.debug('throttled external connect request'); + return { accepted: false }; + } + lastExternalConnectAt = now; + + settleConnectFlow(false); + + const existing = await deliverRelayUrl(relayUrl); + const tabId = existing?.id ?? (await openConnectPopup(relayUrl)); + pendingConnectFlow = { relayUrl, tabId, notify: null }; + return { accepted: true }; +} + +async function openConnectPopup(relayUrl: string): Promise { + const url = `${chrome.runtime.getURL(CONNECT_PAGE)}?mcpRelayUrl=${encodeURIComponent(relayUrl)}`; + let left: number | undefined; + let top: number | undefined; + try { + const focused = await chrome.windows.getLastFocused(); + if (focused.left !== undefined && focused.width !== undefined) { + left = Math.max(0, Math.round(focused.left + (focused.width - CONNECT_POPUP_WIDTH) / 2)); + } + if (focused.top !== undefined && focused.height !== undefined) { + top = Math.max(0, Math.round(focused.top + (focused.height - CONNECT_POPUP_HEIGHT) / 2)); + } + } catch { + // No focused window — let Chrome pick the position + } + const popup = await chrome.windows.create({ + url, + type: 'popup', + width: CONNECT_POPUP_WIDTH, + height: CONNECT_POPUP_HEIGHT, + left, + top, + }); + return popup?.tabs?.[0]?.id ?? null; +} + // --------------------------------------------------------------------------- // Tab lifecycle listeners — only auto-register agent-created tabs // --------------------------------------------------------------------------- @@ -261,6 +390,9 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { }); chrome.tabs.onRemoved.addListener((tabId) => { + if (pendingConnectFlow?.tabId === tabId && !activeConnection) { + settleConnectFlow(false); + } if (!activeConnection) return; log.debug('tab removed:', tabId); activeConnection.relay.removeTab(tabId); @@ -319,7 +451,7 @@ async function connectToRelay( throw error; } - activeConnection = { relay }; + activeConnection = { relay, relayUrl }; relay.onclose = () => { log.debug('relay connection closed'); @@ -337,6 +469,7 @@ async function connectToRelay( log.debug('connected, controlling', tabCount, 'tabs'); updateBadge(tabCount); broadcastStatusChange(); + settleConnectFlow(pendingConnectFlow?.relayUrl === relayUrl); return { success: true }; } catch (error) { log.error('connectToRelay failed:', error); diff --git a/packages/@n8n/mcp-browser-extension/src/relayAllowlist.test.ts b/packages/@n8n/mcp-browser-extension/src/relayAllowlist.test.ts index 191f0ed3c15..dfb3d19ac95 100644 --- a/packages/@n8n/mcp-browser-extension/src/relayAllowlist.test.ts +++ b/packages/@n8n/mcp-browser-extension/src/relayAllowlist.test.ts @@ -1,4 +1,9 @@ -import { getRelayHost, isAllowedRelayUrl, isLocalhostRelay } from './relayAllowlist'; +import { + getRelayHost, + isAllowedPageOrigin, + isAllowedRelayUrl, + isLocalhostRelay, +} from './relayAllowlist'; describe('isAllowedRelayUrl', () => { it('allows n8n cloud tenant subdomains over wss', () => { @@ -41,6 +46,35 @@ describe('isAllowedRelayUrl', () => { }); }); +describe('isAllowedPageOrigin', () => { + it('allows n8n cloud origins over https', () => { + expect(isAllowedPageOrigin('https://acme.app.n8n.cloud')).toBe(true); + expect(isAllowedPageOrigin('https://acme.stage-app.n8n.cloud')).toBe(true); + }); + + it('allows local origins over http and https', () => { + expect(isAllowedPageOrigin('http://localhost:5678')).toBe(true); + expect(isAllowedPageOrigin('https://localhost:5678')).toBe(true); + expect(isAllowedPageOrigin('http://127.0.0.1:5678')).toBe(true); + }); + + it('rejects cloud hosts over plain http', () => { + expect(isAllowedPageOrigin('http://acme.app.n8n.cloud')).toBe(false); + }); + + it('rejects unrecognized and suffix-spoofing origins', () => { + expect(isAllowedPageOrigin('https://evil.example.com')).toBe(false); + expect(isAllowedPageOrigin('https://app.n8n.cloud.evil.com')).toBe(false); + }); + + it('rejects malformed or empty input', () => { + expect(isAllowedPageOrigin('not a url')).toBe(false); + expect(isAllowedPageOrigin('')).toBe(false); + expect(isAllowedPageOrigin(null)).toBe(false); + expect(isAllowedPageOrigin(undefined)).toBe(false); + }); +}); + describe('isLocalhostRelay', () => { it('is true only for local hosts', () => { expect(isLocalhostRelay('ws://localhost:5680/x')).toBe(true); diff --git a/packages/@n8n/mcp-browser-extension/src/relayAllowlist.ts b/packages/@n8n/mcp-browser-extension/src/relayAllowlist.ts index 100e183e99a..a63b9ef580f 100644 --- a/packages/@n8n/mcp-browser-extension/src/relayAllowlist.ts +++ b/packages/@n8n/mcp-browser-extension/src/relayAllowlist.ts @@ -25,7 +25,22 @@ export function isAllowedRelayUrl(url: string | null | undefined): boolean { return false; } if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return false; - const host = parsed.hostname; + return isAllowedHost(parsed.hostname); +} + +export function isAllowedPageOrigin(origin: string | null | undefined): boolean { + if (!origin) return false; + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return false; + } + if (parsed.protocol === 'https:') return isAllowedHost(parsed.hostname); + return parsed.protocol === 'http:' && LOCAL_HOSTS.has(parsed.hostname); +} + +function isAllowedHost(host: string): boolean { if (LOCAL_HOSTS.has(host)) return true; return N8N_CLOUD_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix)); } diff --git a/packages/@n8n/mcp-browser-extension/src/types.ts b/packages/@n8n/mcp-browser-extension/src/types.ts index bc9e97d20d4..8d56343d1e9 100644 --- a/packages/@n8n/mcp-browser-extension/src/types.ts +++ b/packages/@n8n/mcp-browser-extension/src/types.ts @@ -73,6 +73,30 @@ export type ExtensionMessage = | GetRelayUrlMessage | ClearRelayUrlMessage; +// --------------------------------------------------------------------------- +// External messages (web page → background, via externally_connectable) +// --------------------------------------------------------------------------- + +export interface ExternalConnectMessage { + type: 'connect'; + relayUrl: string; +} + +export interface ExternalConnectResultMessage { + type: 'connectResult'; + relayUrl: string; +} + +export type ExternalMessage = ExternalConnectMessage | ExternalConnectResultMessage; + +export interface ExternalConnectResponse { + accepted: boolean; +} + +export interface ExternalConnectResultResponse { + connected: boolean; +} + // --------------------------------------------------------------------------- // Background → UI push messages // --------------------------------------------------------------------------- @@ -94,6 +118,14 @@ export type BackgroundPushMessage = RelayUrlReadyMessage | StatusChangedMessage; // Type guards // --------------------------------------------------------------------------- +export function isExternalMessage(raw: unknown): raw is ExternalMessage { + if (raw === null || typeof raw !== 'object') return false; + const obj = raw as Record; + return ( + (obj.type === 'connect' || obj.type === 'connectResult') && typeof obj.relayUrl === 'string' + ); +} + export function isConnectResponse(raw: unknown): raw is ConnectResponse { if (raw === null || typeof raw !== 'object') return false; const obj = raw as Record; diff --git a/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.test.ts b/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.test.ts index 90674918447..99b48d9d868 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.test.ts +++ b/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.test.ts @@ -57,6 +57,9 @@ const chromeMock = { }), }, }, + windows: { + getCurrent: vi.fn(), + }, }; Object.assign(globalThis, { chrome: chromeMock }); @@ -137,6 +140,7 @@ beforeEach(() => { }); chromeMock.tabs.get.mockImplementation(async (id: number) => await Promise.resolve(makeTab(id))); + chromeMock.windows.getCurrent.mockResolvedValue({ type: 'normal' } as chrome.windows.Window); }); // --------------------------------------------------------------------------- @@ -413,6 +417,42 @@ describe('useConnection', () => { wrapper.unmount(); }); + // Runs a successful connect and reports whether the page closed itself. + async function connectAndCheckWindowClosed(windowType: string): Promise { + chromeMock.windows.getCurrent.mockResolvedValue({ + type: windowType, + } as chrome.windows.Window); + chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { + if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; + if (msg.type === 'getRelayUrl') return 'ws://localhost:1234'; + if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; + if (msg.type === 'getTabs') return [makeTab(1)]; + if (msg.type === 'connect') return { success: true }; + if (msg.type === 'clearRelayUrl') return { success: true }; + return await Promise.resolve({}); + }); + const closeSpy = vi.spyOn(window, 'close').mockImplementation(() => {}); + + const { wrapper, result } = mountComposable(); + await flush(); + + await result().connect(); + await flush(); + + const closed = closeSpy.mock.calls.length > 0; + closeSpy.mockRestore(); + wrapper.unmount(); + return closed; + } + + it('closes the window after connecting when opened as a popup', async () => { + expect(await connectAndCheckWindowClosed('popup')).toBe(true); + }); + + it('keeps the window open after connecting in a regular tab', async () => { + expect(await connectAndCheckWindowClosed('normal')).toBe(false); + }); + it('does nothing when no relay URL is available', async () => { chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; diff --git a/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.ts b/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.ts index b44d2cbfc95..fe1089a16e5 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.ts +++ b/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.ts @@ -152,6 +152,10 @@ export function useConnection() { if (isStatusResponse(statusResponse)) { controlledTabIds.value = statusResponse.tabIds ?? []; } + const currentWindow = await chrome.windows.getCurrent(); + if (currentWindow.type === 'popup') { + window.close(); + } } else { status.value = 'disconnected'; errorMessage.value = isConnectResponse(raw) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 5e3fdcd9cda..5f2963195d4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -6773,6 +6773,9 @@ "instanceAi.browserUse.step.connect.description": "Open the extension's connect page, choose which tabs to share, and click Connect.", "instanceAi.browserUse.step.connect.cta": "Open Browser Use extension", "instanceAi.browserUse.step.connect.extensionMissing": "We can't detect the extension. Install it above, or check that it's enabled in your browser.", + "instanceAi.browserUse.directConnect.waiting": "Confirm the connection in the extension popup…", + "instanceAi.browserUse.directConnect.failed": "Problems connecting your browser?", + "instanceAi.browserUse.directConnect.retry": "Try again", "instanceAi.browserUse.unsupportedBrowser": "Browser Use requires Google Chrome or another Chromium-based browser like Microsoft Edge or Brave. Open n8n in a supported browser to continue.", "instanceAi.browserUse.connected": "Browser Use is connected", "instanceAi.browserUse.connected.description": "The agent can now use your browser. You can disconnect anytime from the extension or from the Connections list.", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiBrowserUse.telemetry.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiBrowserUse.telemetry.test.ts index 022eb7c1159..7513cdcf255 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiBrowserUse.telemetry.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiBrowserUse.telemetry.test.ts @@ -40,4 +40,11 @@ describe('instance ai browser use telemetry', () => { expect(track).toHaveBeenCalledTimes(1); expect(track).toHaveBeenCalledWith('Instance AI Open Browser Use Extension button clicked'); }); + + test('tracks a direct connect request', () => { + useInstanceAiBrowserUseTelemetry().trackDirectConnectRequested(); + + expect(track).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith('Instance AI Browser Use direct connect requested'); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseConnectStep.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseConnectStep.vue new file mode 100644 index 00000000000..2e5e042921c --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseConnectStep.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseSetupContent.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseSetupContent.vue index 465f0b35229..065d288a1fa 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseSetupContent.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseSetupContent.vue @@ -1,5 +1,5 @@ diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseConnectStep.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseConnectStep.test.ts new file mode 100644 index 00000000000..61b7c5a86a6 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseConnectStep.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fireEvent } from '@testing-library/vue'; +import { flushPromises } from '@vue/test-utils'; +import { createComponentRenderer } from '@/__tests__/render'; +import BrowserUseConnectStep from '../BrowserUseConnectStep.vue'; + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ + baseText: (key: string) => key, + }), +})); + +const { telemetryMock } = vi.hoisted(() => ({ + telemetryMock: { + trackModalOpened: vi.fn(), + trackInstallExtensionClicked: vi.fn(), + trackOpenExtensionClicked: vi.fn(), + trackDirectConnectRequested: vi.fn(), + }, +})); + +vi.mock('../../../instanceAiBrowserUse.telemetry', () => ({ + useInstanceAiBrowserUseTelemetry: () => telemetryMock, +})); + +const settingsStoreMock = vi.fn(); +vi.mock('../../../instanceAiSettings.store', () => ({ + useInstanceAiSettingsStore: () => settingsStoreMock(), +})); + +const RELAY_URL = 'wss://acme.app.n8n.cloud/browser-use/extension/session?token=bu_x'; +const CONNECT_URL = `chrome-extension://testextensionid/connect.html?mcpRelayUrl=${encodeURIComponent(RELAY_URL)}`; + +function makeSettingsStore(overrides: Record = {}) { + return { + browserConnectUrlExpiresAt: null, + fetchBrowserConnectUrl: vi.fn().mockResolvedValue(CONNECT_URL), + clearBrowserConnectUrl: vi.fn(), + ...overrides, + }; +} + +function installExtensionMock(responses: Record): void { + const runtime = { + lastError: undefined as { message?: string } | undefined, + sendMessage: ( + _extensionId: string, + message: unknown, + callback: (response: unknown) => void, + ) => { + const type = (message as { type: string }).type; + if (type in responses) callback(responses[type]); + }, + }; + (globalThis as { chrome?: unknown }).chrome = { runtime }; +} + +const renderComponent = createComponentRenderer(BrowserUseConnectStep, { + props: { autoConnect: true }, +}); + +describe('BrowserUseConnectStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + settingsStoreMock.mockReturnValue(makeSettingsStore()); + }); + + afterEach(() => { + delete (globalThis as { chrome?: unknown }).chrome; + }); + + it('does not start a direct connect attempt when autoConnect is off', async () => { + installExtensionMock({ connect: { accepted: true } }); + const { getByTestId, queryByTestId } = createComponentRenderer(BrowserUseConnectStep)(); + await flushPromises(); + + expect(getByTestId('browser-use-open-connect-page')).toBeVisible(); + expect(queryByTestId('browser-use-direct-connect-waiting')).toBeNull(); + expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled(); + }); + + it('shows the manual connect link when the extension cannot be messaged', async () => { + const { getByTestId, queryByTestId } = renderComponent(); + await flushPromises(); + + expect(getByTestId('browser-use-open-connect-page')).toBeVisible(); + expect(queryByTestId('browser-use-direct-connect-waiting')).toBeNull(); + }); + + it('shows the manual connect link when the extension does not accept the request', async () => { + installExtensionMock({ connect: { accepted: false } }); + const { getByTestId, queryByTestId } = renderComponent(); + await flushPromises(); + + expect(getByTestId('browser-use-open-connect-page')).toBeVisible(); + expect(queryByTestId('browser-use-direct-connect-retry')).toBeNull(); + }); + + it('waits for the connect result once the extension opened the popup', async () => { + installExtensionMock({ connect: { accepted: true } }); + const { getByTestId, queryByTestId } = renderComponent(); + await flushPromises(); + + expect(getByTestId('browser-use-direct-connect-waiting')).toBeVisible(); + expect(queryByTestId('browser-use-open-connect-page')).toBeNull(); + expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(1); + }); + + it('offers a retry when the connect did not succeed', async () => { + installExtensionMock({ connect: { accepted: true }, connectResult: { connected: false } }); + const { getByTestId } = renderComponent(); + await flushPromises(); + + await fireEvent.click(getByTestId('browser-use-direct-connect-retry')); + await flushPromises(); + + expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(2); + }); + + it('shows the manual connect link when the extension stops responding on retry', async () => { + installExtensionMock({ connect: { accepted: true }, connectResult: { connected: false } }); + const { getByTestId } = renderComponent(); + await flushPromises(); + + delete (globalThis as { chrome?: unknown }).chrome; + await fireEvent.click(getByTestId('browser-use-direct-connect-retry')); + await flushPromises(); + + expect(getByTestId('browser-use-open-connect-page')).toBeVisible(); + }); + + it('opens the connect page as a popup on the manual connect button', async () => { + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + const { getByTestId } = renderComponent(); + await flushPromises(); + + await fireEvent.click(getByTestId('browser-use-open-connect-page')); + + expect(telemetryMock.trackOpenExtensionClicked).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledWith( + CONNECT_URL, + 'n8n-browser-use-connect', + expect.stringMatching(/^popup,width=620,height=640,left=\d+,top=\d+$/), + ); + + openSpy.mockRestore(); + }); + + it('clears the stored connect URL when unmounted', async () => { + const store = makeSettingsStore(); + settingsStoreMock.mockReturnValue(store); + + const { unmount } = renderComponent(); + await flushPromises(); + unmount(); + + expect(store.clearBrowserConnectUrl).toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseSetupModal.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseSetupModal.test.ts index b0568c484bf..7fabc7d5bf4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseSetupModal.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseSetupModal.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { fireEvent } from '@testing-library/vue'; import { flushPromises } from '@vue/test-utils'; +import { reactive } from 'vue'; import { createComponentRenderer } from '@/__tests__/render'; import BrowserUseSetupContent from '../BrowserUseSetupContent.vue'; import BrowserUseSetupModal from '../BrowserUseSetupModal.vue'; @@ -18,6 +19,7 @@ const { telemetryMock } = vi.hoisted(() => ({ trackModalOpened: vi.fn(), trackInstallExtensionClicked: vi.fn(), trackOpenExtensionClicked: vi.fn(), + trackDirectConnectRequested: vi.fn(), }, })); @@ -35,17 +37,35 @@ vi.mock('../../../utils/browserUseExtension', () => ({ detectBrowserUseExtension: detectExtensionMock, })); +const CONNECT_URL = + 'chrome-extension://testextensionid/connect.html?mcpRelayUrl=wss%3A%2F%2Facme.app.n8n.cloud%2Frelay'; + function makeSettingsStore(overrides: Record = {}) { return { browserConnected: false, browserConnectUrlExpiresAt: null, fetchBrowserStatus: vi.fn(), - fetchBrowserConnectUrl: vi.fn().mockResolvedValue('https://connect.example/extension'), + fetchBrowserConnectUrl: vi.fn().mockResolvedValue(CONNECT_URL), clearBrowserConnectUrl: vi.fn(), ...overrides, }; } +function installExtensionMock(responses: Record): void { + const runtime = { + lastError: undefined as { message?: string } | undefined, + sendMessage: ( + _extensionId: string, + message: unknown, + callback: (response: unknown) => void, + ) => { + const type = (message as { type: string }).type; + if (type in responses) callback(responses[type]); + }, + }; + (globalThis as { chrome?: unknown }).chrome = { runtime }; +} + const renderComponent = createComponentRenderer(BrowserUseSetupModal, { props: { modalName: 'browserUseSetup' }, global: { @@ -81,6 +101,10 @@ describe('BrowserUseSetupModal', () => { detectExtensionMock.mockResolvedValue('unknown'); }); + afterEach(() => { + delete (globalThis as { chrome?: unknown }).chrome; + }); + it('tracks the modal opening on mount', () => { renderComponent(); expect(telemetryMock.trackModalOpened).toHaveBeenCalledTimes(1); @@ -97,12 +121,15 @@ describe('BrowserUseSetupModal', () => { }); it('tracks the open extension button click', async () => { + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); const { getByTestId } = renderComponent(); await flushPromises(); await fireEvent.click(getByTestId('browser-use-open-connect-page')); expect(telemetryMock.trackOpenExtensionClicked).toHaveBeenCalledTimes(1); + + openSpy.mockRestore(); }); it('replaces the connect step with an explanation when the extension is not installed', async () => { @@ -167,6 +194,20 @@ describe('BrowserUseSetupModal', () => { expect(detectExtensionMock).toHaveBeenCalledTimes(1); }); + it('starts a fresh direct connect attempt once the extension gets installed', async () => { + installExtensionMock({ connect: { accepted: true } }); + const { getByTestId, queryByTestId } = await renderWithExtensionState('not-installed'); + expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled(); + expect(queryByTestId('browser-use-direct-connect-waiting')).toBeNull(); + + detectExtensionMock.mockResolvedValue('installed'); + window.dispatchEvent(new Event('focus')); + await flushPromises(); + + expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(1); + expect(getByTestId('browser-use-direct-connect-waiting')).toBeVisible(); + }); + it('does not render the connect steps when already connected', () => { settingsStoreMock.mockReturnValue(makeSettingsStore({ browserConnected: true })); const { queryByTestId } = renderComponent(); @@ -175,6 +216,42 @@ describe('BrowserUseSetupModal', () => { expect(queryByTestId('browser-use-open-connect-page')).toBeNull(); }); + it('keeps the install step visible while the connect step waits for confirmation', async () => { + installExtensionMock({ connect: { accepted: true } }); + const { getByTestId } = renderComponent(); + await flushPromises(); + + // The direct connect flow is scoped to the connect step — the rest of the view stays put. + expect(getByTestId('browser-use-install-extension')).toBeVisible(); + expect(getByTestId('browser-use-direct-connect-waiting')).toBeVisible(); + }); + + it('does not request a direct connection when already connected', async () => { + installExtensionMock({ connect: { accepted: true } }); + settingsStoreMock.mockReturnValue(makeSettingsStore({ browserConnected: true })); + renderComponent(); + await flushPromises(); + + expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled(); + }); + + it('does not request a direct connection when the status fetch reveals a live session', async () => { + installExtensionMock({ connect: { accepted: true } }); + // Store starts stale (disconnected); the status fetch corrects it. Requesting a + // connection here would make the extension drop the live session. + const store = reactive(makeSettingsStore()); + store.fetchBrowserStatus = vi.fn().mockImplementation(async () => { + store.browserConnected = true; + }); + settingsStoreMock.mockReturnValue(store); + + renderComponent(); + await flushPromises(); + + expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled(); + expect(store.fetchBrowserConnectUrl).not.toHaveBeenCalled(); + }); + it('does not render the unsupported browser message on a Chromium browser', () => { const { queryByTestId } = renderComponent(); @@ -225,6 +302,14 @@ describe('BrowserUseSetupModal', () => { expect(store.fetchBrowserConnectUrl).not.toHaveBeenCalled(); }); + it('does not request a direct connection even when the extension is present', async () => { + installExtensionMock({ connect: { accepted: true } }); + renderComponent(); + await flushPromises(); + + expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled(); + }); + it('does not probe for the extension', async () => { renderComponent(); await flushPromises(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useExtensionDirectConnect.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useExtensionDirectConnect.test.ts new file mode 100644 index 00000000000..284c802ed07 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useExtensionDirectConnect.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + useExtensionDirectConnect, + DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS, +} from './useExtensionDirectConnect'; + +const RELAY_URL = 'wss://acme.app.n8n.cloud/browser-use/extension/session?token=bu_x'; +const CONNECT_URL = `chrome-extension://testextensionid/connect.html?mcpRelayUrl=${encodeURIComponent(RELAY_URL)}`; + +type SendMessage = ( + extensionId: string, + message: unknown, + callback: (response: unknown) => void, +) => void; + +const chromeMock: { runtime: { sendMessage: SendMessage; lastError?: { message?: string } } } = { + runtime: { sendMessage: vi.fn() }, +}; + +const HOLD_RESPONSE = Symbol('hold'); +let heldCallbacks: Array<(response: unknown) => void> = []; + +function mockExtensionResponses(responses: Record): void { + chromeMock.runtime.lastError = undefined; + heldCallbacks = []; + chromeMock.runtime.sendMessage = vi.fn( + (_extensionId: string, message: unknown, callback: (response: unknown) => void) => { + const type = (message as { type: string }).type; + if (!(type in responses)) { + chromeMock.runtime.lastError = { message: 'Receiving end does not exist.' }; + callback(undefined); + return; + } + const value = responses[type]; + if (value === HOLD_RESPONSE) { + heldCallbacks.push(callback); + return; + } + callback(value); + }, + ); +} + +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + vi.useFakeTimers(); + (globalThis as { chrome?: unknown }).chrome = chromeMock; + mockExtensionResponses({ connect: { accepted: true }, connectResult: HOLD_RESPONSE }); +}); + +afterEach(() => { + delete (globalThis as { chrome?: unknown }).chrome; + vi.useRealTimers(); +}); + +describe('useExtensionDirectConnect', () => { + it('is unsupported when the page has no extension messaging API', async () => { + delete (globalThis as { chrome?: unknown }).chrome; + const { status, attempt } = useExtensionDirectConnect(); + + await attempt(CONNECT_URL); + + expect(status.value).toBe('unsupported'); + }); + + it('is unsupported when the connect URL lacks a relay URL', async () => { + const { status, attempt } = useExtensionDirectConnect(); + + await attempt('chrome-extension://testextensionid/connect.html'); + + expect(status.value).toBe('unsupported'); + }); + + it('is unsupported when the extension does not answer', async () => { + mockExtensionResponses({}); + const { status, attempt } = useExtensionDirectConnect(); + + await attempt(CONNECT_URL); + + expect(status.value).toBe('unsupported'); + }); + + it('is unsupported when the extension does not accept the request', async () => { + mockExtensionResponses({ connect: { accepted: false } }); + const { status, attempt } = useExtensionDirectConnect(); + + await attempt(CONNECT_URL); + + expect(status.value).toBe('unsupported'); + }); + + it('waits for the connect result after the extension opened the popup', async () => { + const { status, attempt } = useExtensionDirectConnect(); + + void attempt(CONNECT_URL); + await settle(); + + expect(status.value).toBe('waiting'); + expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith( + 'testextensionid', + { type: 'connectResult', relayUrl: RELAY_URL }, + expect.any(Function), + ); + }); + + it('fails as soon as the extension reports an unsuccessful connect', async () => { + const { status, attempt } = useExtensionDirectConnect(); + const pending = attempt(CONNECT_URL); + await settle(); + + heldCallbacks[0]({ connected: false }); + await pending; + + expect(status.value).toBe('failed'); + }); + + it('stays waiting when the extension reports a successful connect', async () => { + const { status, attempt } = useExtensionDirectConnect(); + const pending = attempt(CONNECT_URL); + await settle(); + + heldCallbacks[0]({ connected: true }); + await pending; + + expect(status.value).toBe('waiting'); + }); + + it('fails when no connect result arrives in time', async () => { + const { status, attempt } = useExtensionDirectConnect(); + void attempt(CONNECT_URL); + await settle(); + + vi.advanceTimersByTime(DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS); + await settle(); + + expect(status.value).toBe('failed'); + }); + + it('ignores a second attempt while one is in flight', async () => { + mockExtensionResponses({ connect: HOLD_RESPONSE, connectResult: HOLD_RESPONSE }); + const { status, attempt } = useExtensionDirectConnect(); + void attempt(CONNECT_URL); + await settle(); + void attempt(CONNECT_URL); + await settle(); + + expect(chromeMock.runtime.sendMessage).toHaveBeenCalledTimes(1); + + heldCallbacks[0]({ accepted: true }); + await settle(); + expect(status.value).toBe('waiting'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useExtensionDirectConnect.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useExtensionDirectConnect.ts new file mode 100644 index 00000000000..e22942bd58c --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useExtensionDirectConnect.ts @@ -0,0 +1,108 @@ +import { ref, type Ref } from 'vue'; + +export const DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS = 15_000; + +export type DirectConnectStatus = 'idle' | 'unsupported' | 'waiting' | 'failed'; + +interface ExtensionRuntime { + sendMessage: ( + extensionId: string, + message: unknown, + callback: (response: unknown) => void, + ) => void; + lastError?: { message?: string }; +} + +function getExtensionRuntime(): ExtensionRuntime | null { + const runtime = (globalThis as { chrome?: { runtime?: ExtensionRuntime } }).chrome?.runtime; + return typeof runtime?.sendMessage === 'function' ? runtime : null; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +async function sendToExtension( + runtime: ExtensionRuntime, + extensionId: string, + message: unknown, +): Promise { + return await new Promise((resolve, reject) => { + try { + runtime.sendMessage(extensionId, message, (response) => { + const error = runtime.lastError; + if (error) reject(new Error(error.message ?? 'Extension messaging failed')); + else resolve(response); + }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); +} + +async function timeout(ms: number): Promise { + return await new Promise((resolve) => setTimeout(() => resolve(undefined), ms)); +} + +/** + * Direct Browser Use connect flow: asks the installed extension (via + * `externally_connectable` messaging) to show its connect confirmation in an + * extension-owned popup, so the user only confirms once. `unsupported` means + * the extension did not open the popup — callers show the link-based flow. + */ +export function useExtensionDirectConnect() { + const status: Ref = ref('idle'); + let isAttempting = false; + + async function attempt(connectUrl: string): Promise { + if (isAttempting) return; + isAttempting = true; + try { + await runAttempt(connectUrl); + } finally { + isAttempting = false; + } + } + + async function runAttempt(connectUrl: string): Promise { + const runtime = getExtensionRuntime(); + let extensionId = ''; + let relayUrl: string | null = null; + try { + const parsed = new URL(connectUrl); + extensionId = parsed.hostname; + relayUrl = parsed.searchParams.get('mcpRelayUrl'); + } catch {} + if (!runtime || !extensionId || !relayUrl) { + status.value = 'unsupported'; + return; + } + + try { + const response = await sendToExtension(runtime, extensionId, { type: 'connect', relayUrl }); + if (!isRecord(response) || response.accepted !== true) { + status.value = 'unsupported'; + return; + } + } catch { + status.value = 'unsupported'; + return; + } + + status.value = 'waiting'; + + let connected = false; + try { + const result = await Promise.race([ + sendToExtension(runtime, extensionId, { type: 'connectResult', relayUrl }), + timeout(DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS), + ]); + connected = isRecord(result) && result.connected === true; + } catch {} + if (!connected) { + status.value = 'failed'; + } + } + + return { status, attempt }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiBrowserUse.telemetry.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiBrowserUse.telemetry.ts index 6453115eb65..ac61bad7fd4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiBrowserUse.telemetry.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiBrowserUse.telemetry.ts @@ -14,5 +14,8 @@ export function useInstanceAiBrowserUseTelemetry() { trackOpenExtensionClicked() { telemetry.track('Instance AI Open Browser Use Extension button clicked'); }, + trackDirectConnectRequested() { + telemetry.track('Instance AI Browser Use direct connect requested'); + }, }; }