feat: N8n browser extension supports connecting directly to n8n instance (#32783)

This commit is contained in:
Dimitri Lavrenük
2026-06-24 13:01:09 +00:00
committed by GitHub
parent 52e7df6b59
commit 93c4e7e73d
11 changed files with 405 additions and 41 deletions
@@ -2,10 +2,23 @@
"manifest_version": 3,
"name": "n8n Browser Use",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvRoWEdJjhgP9qs6R1jcemywrw+I91EJZtYur5C97hjjUc5nPao4jSv1qXFksdKuMddb9IEvzBElr5EYsXSaiVqdbRl8Gge0xYV1gGga653T2d9BuXL7NKv/wZxJ2i/coHSjhhIULQUBAVwu0JFMbHY5T8LfqrzBljuY7u1Xa7jmLmx0QrsoKLbGUoOBVZz4ztEGKEQHEelgg+ph2LrcYJczMBZ80PaHQAaWrvbCYF4vnZLd++Svy70ZCt7gr93L8BXHc8j1c3VojQTk+Uvqhm/4nZdYHlEmruQkd0pE+zTyegbcDlw0oc+6sLbsc0CqmJz8zH0OvSTfSVK7h6LNwbQIDAQAB",
"version": "0.0.3",
"version": "0.0.4",
"description": "Let n8n AI set up credentials, fill out forms, and automate anything in your browser",
"permissions": ["debugger", "activeTab", "tabs", "storage", "webNavigation"],
"host_permissions": ["<all_urls>"],
"web_accessible_resources": [
{
"resources": ["connect.html"],
"matches": [
"https://*.app.n8n.cloud/*",
"https://*.stage-app.n8n.cloud/*",
"http://localhost/*",
"https://localhost/*",
"http://127.0.0.1/*",
"https://127.0.0.1/*"
]
}
],
"background": {
"service_worker": "background.mjs",
"type": "module"
@@ -1,6 +1,6 @@
{
"name": "@n8n/mcp-browser-extension",
"version": "0.0.3",
"version": "0.0.4",
"private": true,
"description": "Chrome extension that lets n8n AI control browser tabs via CDP",
"scripts": {
@@ -0,0 +1,150 @@
// ---------------------------------------------------------------------------
// Chrome API mock
//
// `background.ts` registers its listeners at module load, so the mock must be
// installed on `globalThis` before the module is imported (see `beforeAll`).
// ---------------------------------------------------------------------------
type TabUpdatedHandler = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => void;
const EXT_ORIGIN = 'chrome-extension://testextensionid/';
const CONNECT_URL = `${EXT_ORIGIN}connect.html`;
const tabUpdatedListeners: TabUpdatedHandler[] = [];
const chromeMock = {
runtime: {
getURL: vi.fn((path: string) => `${EXT_ORIGIN}${path}`),
sendMessage: vi.fn().mockResolvedValue(undefined),
onMessage: { addListener: vi.fn() },
},
tabs: {
query: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
reload: vi.fn().mockResolvedValue(undefined),
onCreated: { addListener: vi.fn() },
onRemoved: { addListener: vi.fn() },
onUpdated: {
addListener: vi.fn((fn: TabUpdatedHandler) => tabUpdatedListeners.push(fn)),
},
},
windows: { update: vi.fn().mockResolvedValue(undefined) },
storage: {
session: {
set: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue({}),
remove: vi.fn().mockResolvedValue(undefined),
},
local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) },
},
webNavigation: { onCreatedNavigationTarget: { addListener: vi.fn() } },
action: {
onClicked: { addListener: vi.fn() },
setBadgeText: vi.fn(),
setBadgeBackgroundColor: vi.fn(),
},
};
Object.assign(globalThis, { chrome: chromeMock });
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function connectUrlWithRelay(relayUrl: string): string {
return `${CONNECT_URL}?mcpRelayUrl=${encodeURIComponent(relayUrl)}`;
}
/** Invoke the registered tab-update listeners as Chrome would. */
function simulateTabUpdated(tabId: number, url: string): void {
for (const fn of tabUpdatedListeners) fn(tabId, { url } as chrome.tabs.TabChangeInfo);
}
/** Flush pending microtasks/macrotasks so the listener's async IIFE settles. */
const flush = async () => await new Promise((resolve) => setTimeout(resolve, 0));
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeAll(async () => {
await import('./background');
});
beforeEach(() => {
vi.clearAllMocks();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('connect.html tab deduplication', () => {
const NEW_RELAY = 'ws://localhost:2222';
const NEW_TAB_ID = 2;
const EXISTING_TAB_ID = 1;
const EXISTING_WINDOW_ID = 10;
it('reuses an existing connect tab without reloading it when a new relay URL arrives', async () => {
chromeMock.tabs.query.mockResolvedValue([
{
id: EXISTING_TAB_ID,
windowId: EXISTING_WINDOW_ID,
url: connectUrlWithRelay('ws://localhost:1111'),
},
]);
simulateTabUpdated(NEW_TAB_ID, connectUrlWithRelay(NEW_RELAY));
await flush();
expect(chromeMock.tabs.update).toHaveBeenCalledWith(EXISTING_TAB_ID, { active: true });
expect(chromeMock.windows.update).toHaveBeenCalledWith(EXISTING_WINDOW_ID, { focused: true });
expect(chromeMock.tabs.remove).toHaveBeenCalledWith(NEW_TAB_ID);
// Reloading would re-read the existing tab's stale ?mcpRelayUrl — regression guard.
expect(chromeMock.tabs.reload).not.toHaveBeenCalled();
});
it('pushes the new relay URL to the existing tab and stores it as a fallback', async () => {
chromeMock.tabs.query.mockResolvedValue([
{
id: EXISTING_TAB_ID,
windowId: EXISTING_WINDOW_ID,
url: connectUrlWithRelay('ws://localhost:1111'),
},
]);
simulateTabUpdated(NEW_TAB_ID, connectUrlWithRelay(NEW_RELAY));
await flush();
expect(chromeMock.storage.session.set).toHaveBeenCalledWith({ pendingRelayUrl: NEW_RELAY });
expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith({
type: 'relayUrlReady',
relayUrl: NEW_RELAY,
});
});
it('does not touch other tabs when no existing connect tab is open', async () => {
// Only the freshly-opened tab matches the query — nothing to reuse.
chromeMock.tabs.query.mockResolvedValue([
{ id: NEW_TAB_ID, url: connectUrlWithRelay(NEW_RELAY) },
]);
simulateTabUpdated(NEW_TAB_ID, connectUrlWithRelay(NEW_RELAY));
await flush();
expect(chromeMock.storage.session.set).toHaveBeenCalledWith({ pendingRelayUrl: NEW_RELAY });
expect(chromeMock.tabs.update).not.toHaveBeenCalled();
expect(chromeMock.tabs.remove).not.toHaveBeenCalled();
expect(chromeMock.tabs.reload).not.toHaveBeenCalled();
expect(chromeMock.runtime.sendMessage).not.toHaveBeenCalled();
});
it('ignores tab updates that carry no relay URL', async () => {
simulateTabUpdated(NEW_TAB_ID, CONNECT_URL);
await flush();
expect(chromeMock.storage.session.set).not.toHaveBeenCalled();
expect(chromeMock.tabs.query).not.toHaveBeenCalled();
});
});
@@ -6,6 +6,7 @@
*/
import { createLogger } from './logger';
import { isAllowedRelayUrl } from './relayAllowlist';
import { RelayConnection, isEligibleTab } from './relayConnection';
import type { ExtensionMessage, TabManagementSettings } from './types';
@@ -149,17 +150,16 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
// 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 });
await chrome.tabs.reload(existing.id);
if (existing.windowId !== undefined) {
await chrome.windows.update(existing.windowId, { focused: true });
}
await chrome.tabs.remove(tabId);
// Notify existing tab about the new relay URL
// 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 {
// Tab may not have a listener ready yet — it will read from storage on next mount
// 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
@@ -275,6 +275,12 @@ async function connectToRelay(
selectedTabIds: number[],
): Promise<{ success: boolean; error?: string }> {
log.debug('connectToRelay:', relayUrl, 'selectedTabs:', selectedTabIds.length);
if (!isAllowedRelayUrl(relayUrl)) {
log.warn('refusing relay connection to disallowed host:', relayUrl);
return { success: false, error: 'Refusing to connect: not a recognized n8n instance.' };
}
// Clean up existing connection
disconnect();
@@ -0,0 +1,64 @@
import { getRelayHost, isAllowedRelayUrl, isLocalhostRelay } from './relayAllowlist';
describe('isAllowedRelayUrl', () => {
it('allows n8n cloud tenant subdomains over wss', () => {
expect(isAllowedRelayUrl('wss://acme.app.n8n.cloud/browser-use/extension/s?token=t')).toBe(
true,
);
expect(isAllowedRelayUrl('wss://acme.stage-app.n8n.cloud/browser-use/extension/s')).toBe(true);
});
it('allows the bare cloud apex', () => {
expect(isAllowedRelayUrl('wss://app.n8n.cloud/x')).toBe(true);
});
it('allows localhost relays for local development', () => {
expect(isAllowedRelayUrl('ws://localhost:5680/browser-use/cdp/s')).toBe(true);
expect(isAllowedRelayUrl('ws://127.0.0.1:5680/x')).toBe(true);
expect(isAllowedRelayUrl('ws://[::1]:5680/x')).toBe(true);
});
it('rejects unrecognized hosts', () => {
expect(isAllowedRelayUrl('wss://evil.com/x')).toBe(false);
expect(isAllowedRelayUrl('wss://notn8ncloud.com/x')).toBe(false);
});
it('rejects suffix-spoofing hosts', () => {
expect(isAllowedRelayUrl('wss://app.n8n.cloud.evil.com/x')).toBe(false);
expect(isAllowedRelayUrl('wss://evil-app.n8n.cloud.attacker.net/x')).toBe(false);
});
it('rejects non-websocket schemes', () => {
expect(isAllowedRelayUrl('https://acme.app.n8n.cloud/x')).toBe(false);
expect(isAllowedRelayUrl('http://localhost:5680/x')).toBe(false);
});
it('rejects malformed or empty input', () => {
expect(isAllowedRelayUrl('not a url')).toBe(false);
expect(isAllowedRelayUrl('')).toBe(false);
expect(isAllowedRelayUrl(null)).toBe(false);
expect(isAllowedRelayUrl(undefined)).toBe(false);
});
});
describe('isLocalhostRelay', () => {
it('is true only for local hosts', () => {
expect(isLocalhostRelay('ws://localhost:5680/x')).toBe(true);
expect(isLocalhostRelay('ws://127.0.0.1:5680/x')).toBe(true);
expect(isLocalhostRelay('ws://[::1]:5680/x')).toBe(true);
expect(isLocalhostRelay('wss://acme.app.n8n.cloud/x')).toBe(false);
expect(isLocalhostRelay(null)).toBe(false);
});
});
describe('getRelayHost', () => {
it('returns the hostname for a valid URL', () => {
expect(getRelayHost('wss://acme.app.n8n.cloud/x')).toBe('acme.app.n8n.cloud');
});
it('returns null for malformed or empty input', () => {
expect(getRelayHost('not a url')).toBeNull();
expect(getRelayHost(null)).toBeNull();
expect(getRelayHost(undefined)).toBeNull();
});
});
@@ -0,0 +1,31 @@
// Relay hosts the extension is permitted to connect to.
const N8N_CLOUD_SUFFIXES = ['.app.n8n.cloud', '.stage-app.n8n.cloud'];
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
export function getRelayHost(url: string | null | undefined): string | null {
if (!url) return null;
try {
return new URL(url).hostname;
} catch {
return null;
}
}
export function isLocalhostRelay(url: string | null | undefined): boolean {
const host = getRelayHost(url);
return host !== null && LOCAL_HOSTS.has(host);
}
export function isAllowedRelayUrl(url: string | null | undefined): boolean {
if (!url) return false;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return false;
const host = parsed.hostname;
if (LOCAL_HOSTS.has(host)) return true;
return N8N_CLOUD_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
}
@@ -880,3 +880,48 @@ describe('RelayConnection', () => {
});
});
});
describe('RelayConnection keepalive', () => {
let ws: MockWebSocket;
let relay: RelayConnection;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
ws = new MockWebSocket();
relay = new RelayConnection(ws as unknown as WebSocket);
});
afterEach(() => {
vi.useRealTimers();
});
it('sends keepalive frames on an interval while the socket is open', () => {
expect(ws.sent).toHaveLength(0);
vi.advanceTimersByTime(15_000);
expect(ws.sent).toHaveLength(1);
expect(parseSent(ws)).toEqual({ method: 'keepalive' });
vi.advanceTimersByTime(15_000);
expect(ws.sent).toHaveLength(2);
});
it('stops sending keepalive frames after the connection closes', () => {
vi.advanceTimersByTime(15_000);
expect(ws.sent).toHaveLength(1);
relay.close('done');
ws.sent.length = 0;
vi.advanceTimersByTime(45_000);
expect(ws.sent).toHaveLength(0);
});
it('does not send keepalive frames while the socket is not open', () => {
ws.readyState = MockWebSocket.CLOSED;
vi.advanceTimersByTime(15_000);
expect(ws.sent).toHaveLength(0);
});
});
@@ -53,6 +53,7 @@ interface TabEntry {
const CDP_COMMAND_TIMEOUT_MS = 30_000;
const ATTACH_TIMEOUT_MS = 5_000;
const KEEPALIVE_INTERVAL_MS = 15_000;
// ---------------------------------------------------------------------------
// RelayConnection
@@ -88,6 +89,7 @@ export class RelayConnection {
) => void;
private readonly detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
private closed = false;
private keepaliveInterval: ReturnType<typeof setInterval> | undefined;
private settings: TabManagementSettings = DEFAULT_SETTINGS;
onclose?: () => void;
@@ -102,6 +104,8 @@ export class RelayConnection {
this.detachListener = this.onDebuggerDetach.bind(this);
chrome.debugger.onEvent.addListener(this.eventListener);
chrome.debugger.onDetach.addListener(this.detachListener);
this.startKeepalive();
}
// =========================================================================
@@ -218,10 +222,24 @@ export class RelayConnection {
// Internal — connection lifecycle
// =========================================================================
private startKeepalive(): void {
this.keepaliveInterval = setInterval(() => {
this.sendMessage({ method: 'keepalive' });
}, KEEPALIVE_INTERVAL_MS);
}
private stopKeepalive(): void {
if (this.keepaliveInterval) {
clearInterval(this.keepaliveInterval);
this.keepaliveInterval = undefined;
}
}
private handleClose(): void {
if (this.closed) return;
this.closed = true;
this.stopKeepalive();
chrome.debugger.onEvent.removeListener(this.eventListener);
chrome.debugger.onDetach.removeListener(this.detachListener);
@@ -12,6 +12,8 @@ const {
errorMessage,
settings,
hasRelayUrl,
relayHost,
isRelayAllowed,
isAutoConnect,
controlledTabs,
controlledTabIds,
@@ -39,27 +41,37 @@ const {
<template v-if="status !== 'connected'">
<template v-if="hasRelayUrl">
<TabList
v-if="tabs.length"
:tabs="tabs"
selectable
:selected-tab-ids="selectedTabIds"
:all-selected="allSelected"
@toggle-tab="toggleTab"
@toggle-all="toggleAll"
/>
<N8nButton
class="full-width"
size="large"
:disabled="status === 'connecting'"
@click="connect"
>
Connect{{
someSelected
? ` (${selectedTabIds.size} tab${selectedTabIds.size !== 1 ? 's' : ''})`
: ''
}}
</N8nButton>
<template v-if="isRelayAllowed">
<p class="connect-notice">
You're about to connect to <strong>{{ relayHost }}</strong
>. Only continue if you started this connection yourself.
</p>
<TabList
v-if="tabs.length"
:tabs="tabs"
selectable
:selected-tab-ids="selectedTabIds"
:all-selected="allSelected"
@toggle-tab="toggleTab"
@toggle-all="toggleAll"
/>
<N8nButton
class="full-width"
size="large"
:disabled="status === 'connecting'"
@click="connect"
>
Connect{{
someSelected
? ` (${selectedTabIds.size} tab${selectedTabIds.size !== 1 ? 's' : ''})`
: ''
}}
</N8nButton>
</template>
<p v-else class="error">
Can't connect to <strong>{{ relayHost || 'this address' }}</strong> — it isn't a valid n8n
instance.
</p>
</template>
<p v-else class="info-text">
Waiting for n8n AI to connect. Ask n8n AI to open your browser to get started.
@@ -125,6 +137,13 @@ const {
margin: 0 0 var(--spacing--sm);
}
.connect-notice {
font-size: var(--font-size--sm);
line-height: var(--line-height--xl);
color: var(--color--text);
margin: 0 0 var(--spacing--sm);
}
.full-width {
width: 100%;
}
@@ -639,6 +639,25 @@ describe('useConnection', () => {
wrapper.unmount();
});
it('strips the stale connection params from the page URL', async () => {
window.history.replaceState(
{},
'',
'/?mcpRelayUrl=' + encodeURIComponent('ws://localhost:1111') + '&autoConnect=1',
);
const { wrapper, result } = mountComposable();
await flush();
pushMessage({ type: 'relayUrlReady', relayUrl: 'ws://localhost:9999' });
await flush();
expect(result().relayUrl.value).toBe('ws://localhost:9999');
expect(window.location.search).toBe('');
wrapper.unmount();
});
});
describe('cleanup', () => {
@@ -1,6 +1,7 @@
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue';
import { createLogger } from '../../logger';
import { getRelayHost, isAllowedRelayUrl, isLocalhostRelay } from '../../relayAllowlist';
import { isEligibleTab } from '../../relayConnection';
import type {
ConnectionStatus,
@@ -57,6 +58,8 @@ export function useConnection() {
// ── Computeds ─────────────────────────────────────────────────────────────
const hasRelayUrl = computed(() => !!relayUrl.value);
const relayHost = computed(() => getRelayHost(relayUrl.value));
const isRelayAllowed = computed(() => isAllowedRelayUrl(relayUrl.value));
const allSelected = computed(
() =>
@@ -124,6 +127,12 @@ export function useConnection() {
return;
}
if (!isAllowedRelayUrl(relayUrl.value)) {
errorMessage.value = `Can't connect to ${relayHost.value ?? 'this address'} — not a recognized n8n instance.`;
log.warn('connect: relay URL not allowed', relayUrl.value);
return;
}
log.debug('connect: relay URL =', relayUrl.value, 'selectedTabs:', selectedTabIds.size);
status.value = 'connecting';
errorMessage.value = '';
@@ -174,6 +183,9 @@ export function useConnection() {
if (message.type === 'relayUrlReady' && message.relayUrl) {
log.debug('relayUrlReady received:', message.relayUrl);
relayUrl.value = message.relayUrl;
// Drop the now-stale connection params from the page URL. The live value lives in
// relayUrl + session storage, so a manual reload reads the fresh URL, not the old token.
window.history.replaceState(null, '', window.location.pathname);
if (status.value === 'connected') {
status.value = 'disconnected';
controlledTabIds.value = []; // controlledTabDetails auto-computes to []
@@ -282,6 +294,8 @@ export function useConnection() {
settings,
relayUrl,
hasRelayUrl,
relayHost,
isRelayAllowed,
isAutoConnect,
controlledTabs: controlledTabDetails,
allSelected,
@@ -293,18 +307,3 @@ export function useConnection() {
updateSettings,
};
}
/**
* Returns true if the relay URL points to the local machine. Used to gate
* the `?autoConnect=1` shortcut so a crafted chrome-extension URL with a
* remote `mcpRelayUrl` cannot trigger an unattended connect.
*/
function isLocalhostRelay(url: string | null): boolean {
if (!url) return false;
try {
const { hostname } = new URL(url);
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]';
} catch {
return false;
}
}