feat: Remember allowed Browser Use hosts (no-changelog) (#36744)

This commit is contained in:
Bernhard Wittmann
2026-08-24 06:33:59 +00:00
committed by GitHub
parent d008bb9bd5
commit 32bc2e0ea1
28 changed files with 1636 additions and 207 deletions
@@ -0,0 +1,105 @@
import {
forgetApprovedHost,
isHostApproved,
listApprovedHosts,
rememberHost,
} from './approvedHosts';
const KEY = 'approvedRelayHosts';
const storage = {
get: vi.fn(),
set: vi.fn().mockResolvedValue(undefined),
};
Object.assign(globalThis, { chrome: { storage: { local: storage } } });
/** Seed what `chrome.storage.local.get` reports for the approved-hosts key. */
function seed(value: unknown): void {
storage.get.mockResolvedValue({ [KEY]: value });
}
beforeEach(() => {
vi.clearAllMocks();
seed(undefined);
storage.set.mockResolvedValue(undefined);
});
describe('isHostApproved', () => {
it('is true for a stored host', async () => {
seed(['acme.app.n8n.cloud']);
expect(await isHostApproved('wss://acme.app.n8n.cloud/browser-use/x?token=y')).toBe(true);
});
it('is false when nothing is stored', async () => {
expect(await isHostApproved('wss://acme.app.n8n.cloud/x')).toBe(false);
});
it('treats the same hostname on a different port as a different instance', async () => {
seed(['localhost:5678']);
expect(await isHostApproved('ws://localhost:5678/x')).toBe(true);
expect(await isHostApproved('ws://localhost:5679/x')).toBe(false);
});
it('is false for an unparseable URL', async () => {
seed(['acme.app.n8n.cloud']);
expect(await isHostApproved('not a url')).toBe(false);
expect(await isHostApproved(null)).toBe(false);
});
it('ignores a malformed stored value', async () => {
seed({ notAnArray: true });
expect(await isHostApproved('wss://acme.app.n8n.cloud/x')).toBe(false);
});
it('ignores non-string entries', async () => {
seed([42, 'acme.app.n8n.cloud']);
expect(await isHostApproved('wss://acme.app.n8n.cloud/x')).toBe(true);
});
});
describe('rememberHost', () => {
it('appends the host and port', async () => {
seed(['other.app.n8n.cloud']);
await rememberHost('ws://localhost:5678/x');
expect(storage.set).toHaveBeenCalledWith({
[KEY]: ['other.app.n8n.cloud', 'localhost:5678'],
});
});
it('does not duplicate an already-stored host', async () => {
seed(['acme.app.n8n.cloud']);
await rememberHost('wss://acme.app.n8n.cloud/x');
expect(storage.set).not.toHaveBeenCalled();
});
it('is a no-op for an unparseable URL', async () => {
await rememberHost('not a url');
expect(storage.set).not.toHaveBeenCalled();
});
});
describe('forgetApprovedHost', () => {
it('removes only the named host', async () => {
seed(['acme.app.n8n.cloud', 'localhost:5678']);
await forgetApprovedHost('localhost:5678');
expect(storage.set).toHaveBeenCalledWith({ [KEY]: ['acme.app.n8n.cloud'] });
});
it('is a no-op when the host was never stored', async () => {
seed(['acme.app.n8n.cloud']);
await forgetApprovedHost('localhost:5678');
expect(storage.set).not.toHaveBeenCalled();
});
});
describe('listApprovedHosts', () => {
it('returns every stored host so they can be managed', async () => {
seed(['acme.app.n8n.cloud', 'localhost:5678']);
expect(await listApprovedHosts()).toEqual(['acme.app.n8n.cloud', 'localhost:5678']);
});
it('is empty when nothing was ever remembered', async () => {
expect(await listApprovedHosts()).toEqual([]);
});
});
@@ -0,0 +1,40 @@
/**
* Hosts the user chose to keep approved, so repeat sessions skip the connect
* confirmation. `local`, not `session`, so the choice survives a browser restart. This
* never widens what may be connected to — `relayAllowlist` still gates every relay URL.
*/
import { getRelayHostKey } from './relayAllowlist';
const APPROVED_HOSTS_KEY = 'approvedRelayHosts';
export async function listApprovedHosts(): Promise<string[]> {
const stored = await chrome.storage.local.get(APPROVED_HOSTS_KEY);
const value: unknown = stored[APPROVED_HOSTS_KEY];
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === 'string');
}
export async function isHostApproved(relayUrl: string | null | undefined): Promise<boolean> {
const host = getRelayHostKey(relayUrl);
if (!host) return false;
return (await listApprovedHosts()).includes(host);
}
/** Returns the resulting list, so callers refresh their view without a second read. */
export async function rememberHost(relayUrl: string | null | undefined): Promise<string[]> {
const host = getRelayHostKey(relayUrl);
const hosts = await listApprovedHosts();
if (!host || hosts.includes(host)) return hosts;
const next = [...hosts, host];
await chrome.storage.local.set({ [APPROVED_HOSTS_KEY]: next });
return next;
}
export async function forgetApprovedHost(host: string): Promise<string[]> {
const hosts = await listApprovedHosts();
if (!hosts.includes(host)) return hosts;
const next = hosts.filter((entry) => entry !== host);
await chrome.storage.local.set({ [APPROVED_HOSTS_KEY]: next });
return next;
}
@@ -0,0 +1,295 @@
// Separate from `background.test.ts` because it mocks `RelayConnection` to observe what
// the handshake is handed, which that file's cases must not see.
type ExternalMessageHandler = (
message: unknown,
sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void,
) => unknown;
const ALLOWED_ORIGIN = 'https://acme.app.n8n.cloud';
const RELAY_URL = 'wss://acme.app.n8n.cloud/browser-use/extension/abc?token=bu_x';
const { registerSelectedTabs } = vi.hoisted(() => ({
registerSelectedTabs: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./relayConnection', () => ({
isEligibleTab: () => true,
// eslint-disable-next-line @typescript-eslint/naming-convention -- must match the real export
RelayConnection: class {
onclose: (() => void) | null = null;
ontabcreated: (() => void) | null = null;
registerSelectedTabs = registerSelectedTabs;
close = vi.fn();
getControlledIds = () => [];
isAgentCreatedTab = () => false;
isControlledTab = () => false;
},
}));
type InternalMessageHandler = (
message: unknown,
sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void,
) => unknown;
const externalMessageListeners: ExternalMessageHandler[] = [];
const internalMessageListeners: InternalMessageHandler[] = [];
const chromeMock = {
runtime: {
getURL: (path: string) => `chrome-extension://testextensionid/${path}`,
getManifest: () => ({ version: '0.0.6' }),
sendMessage: vi.fn().mockResolvedValue(undefined),
onMessage: {
addListener: vi.fn((fn: InternalMessageHandler) => internalMessageListeners.push(fn)),
},
onMessageExternal: {
addListener: vi.fn((fn: ExternalMessageHandler) => externalMessageListeners.push(fn)),
},
},
tabs: {
query: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
onCreated: { addListener: vi.fn() },
onRemoved: { addListener: vi.fn() },
onUpdated: { addListener: vi.fn() },
},
windows: {
update: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue({ tabs: [{ id: 99 }] }),
getLastFocused: vi.fn().mockResolvedValue({ left: 0, top: 0, width: 1920, height: 1080 }),
},
storage: {
session: {
set: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue({}),
remove: vi.fn().mockResolvedValue(undefined),
},
local: {
get: vi.fn().mockResolvedValue({ approvedRelayHosts: ['acme.app.n8n.cloud'] }),
set: vi.fn().mockResolvedValue(undefined),
},
},
webNavigation: { onCreatedNavigationTarget: { addListener: vi.fn() } },
action: {
setBadgeText: vi.fn(),
setBadgeBackgroundColor: vi.fn(),
setPopup: vi.fn(),
onClicked: { addListener: vi.fn() },
},
};
Object.assign(globalThis, { chrome: chromeMock });
/** Relay socket whose handshake the test completes by hand, to interleave two connects. */
function stubDeferredRelaySocket(): { openAll: () => void } {
const opens: Array<() => void> = [];
vi.stubGlobal(
'WebSocket',
class {
onopen: (() => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
constructor(public url: string) {
opens.push(() => this.onopen?.());
}
close(): void {}
},
);
return {
openAll: () => {
// Reverse, so the connect that started first is the last to finish.
for (const open of [...opens].reverse()) open();
},
};
}
/** Relay socket double, so the handshake resolves without a network. */
function stubRelaySocket(opens: boolean): void {
vi.stubGlobal(
'WebSocket',
class {
onopen: (() => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
constructor(public url: string) {
setTimeout(() => (opens ? this.onopen?.() : this.onerror?.({})), 0);
}
close(): void {}
},
);
}
const flush = async () => await new Promise((resolve) => setTimeout(resolve, 0));
/** Drive a message from an extension view, which is how the connect page asks to connect. */
async function sendFromExtensionView(message: unknown): Promise<void> {
for (const fn of internalMessageListeners) {
fn(message, {} as chrome.runtime.MessageSender, () => {});
}
await flush();
}
/** Same as `sendFromExtensionView`, but hands back what the background answered. */
async function sendFromExtensionViewWithResponse(message: unknown): Promise<unknown> {
let response: unknown;
for (const fn of internalMessageListeners) {
fn(message, {} as chrome.runtime.MessageSender, (r) => (response = r));
}
await flush();
return response;
}
/** Returns a getter, since a response can be held open until the flow settles. */
async function sendFromPage(
type: 'connect' | 'connectResult',
origin = ALLOWED_ORIGIN,
relayUrl = RELAY_URL,
): Promise<() => unknown> {
const holder: { value?: unknown } = {};
for (const fn of externalMessageListeners) {
fn({ type, relayUrl }, { origin } as chrome.runtime.MessageSender, (r) => (holder.value = r));
}
await flush();
return () => holder.value;
}
beforeAll(async () => {
await import('./background');
});
// The throttle keys off Date.now(); step past the window before each case.
let nowMs = 0;
beforeEach(() => {
nowMs += 60_000;
vi.spyOn(Date, 'now').mockImplementation(() => nowMs);
registerSelectedTabs.mockClear();
chromeMock.windows.create.mockClear();
chromeMock.action.setPopup.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('silent connect to an approved host', () => {
it('attaches no existing tabs, so a prompt-free reconnect cannot reach them', async () => {
stubRelaySocket(true);
await sendFromPage('connect');
await flush();
expect(registerSelectedTabs).toHaveBeenCalledWith([]);
expect(chromeMock.windows.create).not.toHaveBeenCalled();
});
it('leaves the extension icon usable — there is no pending page to focus', async () => {
stubRelaySocket(true);
await sendFromPage('connect');
await flush();
expect(chromeMock.action.setPopup).not.toHaveBeenCalledWith({ popup: '' });
});
it('reports the connection once the handshake lands', async () => {
stubRelaySocket(true);
await sendFromPage('connect');
await flush();
expect((await sendFromPage('connectResult'))()).toEqual({ connected: true });
});
it('still confirms by hand when a different instance asks', async () => {
stubRelaySocket(true);
// Another allowed origin cannot spend an approval granted to this relay's own page.
const response = await sendFromPage('connect', 'https://other.app.n8n.cloud');
await flush();
expect(response()).toEqual({ accepted: true, confirmationRequired: true });
expect(chromeMock.windows.create).toHaveBeenCalled();
expect(registerSelectedTabs).not.toHaveBeenCalled();
});
it("leaves another instance's pending flow alone when this one lands", async () => {
stubRelaySocket(true);
const otherOrigin = 'https://other.app.n8n.cloud';
const otherRelay = 'wss://other.app.n8n.cloud/browser-use/extension/zzz?token=bu_y';
// An unapproved instance is mid-confirmation: its popup is open and its page is
// holding a `connectResult` open, waiting to hear how that went.
await sendFromPage('connect', otherOrigin, otherRelay);
const otherResult = await sendFromPage('connectResult', otherOrigin, otherRelay);
expect(otherResult()).toBeUndefined();
// Meanwhile a connect page approves a different relay, which reaches connectToRelay
// without going through the supersede at the top of the external handler.
await sendFromExtensionView({ type: 'connect', relayUrl: RELAY_URL, selectedTabIds: [] });
await flush();
// Settling here would tell that page its connect failed while its popup is still up.
expect(otherResult()).toBeUndefined();
});
it('does not let a slower earlier handshake replace the live session', async () => {
const socket = stubDeferredRelaySocket();
// Two connects overlap; the first one's socket is the last to open.
await sendFromExtensionView({ type: 'connect', relayUrl: RELAY_URL, selectedTabIds: [] });
const newerRelay = 'wss://acme.app.n8n.cloud/browser-use/extension/newer?token=bu_z';
await sendFromExtensionView({ type: 'connect', relayUrl: newerRelay, selectedTabIds: [] });
socket.openAll();
await flush();
const status = await sendFromExtensionViewWithResponse({ type: 'getStatus' });
expect(status).toMatchObject({ connected: true, relayUrl: newerRelay });
});
it('honours a disconnect issued while the handshake is still opening', async () => {
const socket = stubDeferredRelaySocket();
await sendFromExtensionView({ type: 'connect', relayUrl: RELAY_URL, selectedTabIds: [] });
await sendFromExtensionView({ type: 'disconnect' });
socket.openAll();
await flush();
// The user asked for no connection; a slow handshake must not deliver one anyway.
expect(await sendFromExtensionViewWithResponse({ type: 'getStatus' })).toMatchObject({
connected: false,
});
});
it('reports failure promptly instead of leaving the page waiting', async () => {
stubRelaySocket(false);
expect((await sendFromPage('connect'))()).toEqual({
accepted: true,
confirmationRequired: false,
});
// Settled by the failed handshake rather than the page's own timeout.
const result = await sendFromPage('connectResult');
await flush();
expect(result()).toEqual({ connected: false });
});
});
export {};
@@ -102,6 +102,19 @@ async function simulateTabRemoved(tabId: number): Promise<void> {
await flush();
}
/** Stands in for the relay socket so no test opens a real connection. */
class FailingWebSocket {
onopen: (() => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
constructor(public url: string) {
setTimeout(() => this.onerror?.({}), 0);
}
close(): void {}
}
/** Flush pending microtasks/macrotasks so the listener's async IIFE settles. */
const flush = async () => await new Promise((resolve) => setTimeout(resolve, 0));
@@ -117,6 +130,11 @@ beforeEach(() => {
vi.clearAllMocks();
});
// In an `afterEach` so a failed assertion can't leak the stub into later cases.
afterEach(() => {
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -202,6 +220,7 @@ describe('external messages (direct connect flow)', () => {
nowMs += 60_000;
vi.spyOn(Date, 'now').mockImplementation(() => nowMs);
chromeMock.tabs.query.mockResolvedValue([]);
chromeMock.storage.local.get.mockResolvedValue({});
chromeMock.windows.create.mockResolvedValue({ tabs: [{ id: POPUP_TAB_ID }] });
chromeMock.windows.getLastFocused.mockResolvedValue({
left: 0,
@@ -241,7 +260,7 @@ describe('external messages (direct connect flow)', () => {
left: 690,
top: 190,
});
expect(response()).toEqual({ accepted: true });
expect(response()).toEqual({ accepted: true, confirmationRequired: true });
});
it('reuses an already-open connect page instead of opening a new popup', async () => {
@@ -261,7 +280,24 @@ describe('external messages (direct connect flow)', () => {
type: 'relayUrlReady',
relayUrl: RELAY_URL,
});
expect(response()).toEqual({ accepted: true });
expect(response()).toEqual({ accepted: true, confirmationRequired: true });
});
it('skips the confirmation popup for a previously approved host', async () => {
chromeMock.storage.local.get.mockResolvedValue({
approvedRelayHosts: ['acme.app.n8n.cloud'],
});
vi.stubGlobal('WebSocket', FailingWebSocket);
const response = await simulateExternalMessage(
{ type: 'connect', relayUrl: RELAY_URL },
ALLOWED_ORIGIN,
);
// No confirmation was shown, so the page must not tell the user to look for one.
expect(response()).toEqual({ accepted: true, confirmationRequired: false });
expect(chromeMock.windows.create).not.toHaveBeenCalled();
expect(chromeMock.tabs.update).not.toHaveBeenCalled();
});
it('rejects a relay URL that is not a recognized n8n instance', async () => {
@@ -297,7 +333,7 @@ describe('external messages (direct connect flow)', () => {
ALLOWED_ORIGIN,
);
expect(first()).toEqual({ accepted: true });
expect(first()).toEqual({ accepted: true, confirmationRequired: true });
expect(second()).toEqual({ accepted: false });
expect(chromeMock.windows.create).toHaveBeenCalledTimes(1);
});
@@ -5,8 +5,9 @@
* and tracks tab lifecycle for agent-created tabs only.
*/
import { isHostApproved } from './approvedHosts';
import { createLogger } from './logger';
import { isAllowedPageOrigin, isAllowedRelayUrl } from './relayAllowlist';
import { getRelayHostKey, isAllowedPageOrigin, isAllowedRelayUrl } from './relayAllowlist';
import { RelayConnection, isEligibleTab } from './relayConnection';
import type {
ExtensionMessage,
@@ -23,6 +24,9 @@ interface ConnectionState {
}
let activeConnection: ConnectionState | null = null;
// Bumped per connect request. A handshake is slow enough that a newer one can finish while
// an older is still opening, so the older must not commit itself over the live session.
let connectGeneration = 0;
/** A query param rather than a header because `WebSocket` cannot set request headers. */
export function buildRelayWsUrl(relayUrl: string, version: string): string {
@@ -244,10 +248,13 @@ chrome.runtime.onMessageExternal.addListener(
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 });
});
void handleExternalConnect(message.relayUrl, sender.origin).then(
sendResponse,
(error: unknown) => {
log.warn('external connect failed:', error);
sendResponse({ accepted: false });
},
);
return true;
}
@@ -264,7 +271,10 @@ chrome.runtime.onMessageExternal.addListener(
},
);
async function handleExternalConnect(relayUrl: string): Promise<ExternalConnectResponse> {
async function handleExternalConnect(
relayUrl: string,
senderOrigin: string | undefined,
): Promise<ExternalConnectResponse> {
if (!isAllowedRelayUrl(relayUrl)) {
log.warn('refusing external connect to disallowed relay:', relayUrl);
return { accepted: false };
@@ -279,11 +289,26 @@ async function handleExternalConnect(relayUrl: string): Promise<ExternalConnectR
settleConnectFlow(false);
// Approved host asked for by its own instance — connect straight through. Pending is set
// before the handshake so a `connectResult` arriving mid-flight can attach its callback,
// and the drawer stays enabled because there is no confirmation page to focus.
const askedByRelayHost = getRelayHostKey(senderOrigin) === getRelayHostKey(relayUrl);
if (askedByRelayHost && (await isHostApproved(relayUrl))) {
log.debug('relay host previously approved, connecting without confirmation:', relayUrl);
const flow: PendingConnectFlow = { relayUrl, tabId: null, notify: null };
pendingConnectFlow = flow;
void connectToRelay(relayUrl, []).then((result) => {
// A newer request may already own the pending flow; only settle our own.
if (!result.success && pendingConnectFlow === flow) settleConnectFlow(false);
});
return { accepted: true, confirmationRequired: false };
}
const existing = await deliverRelayUrl(relayUrl);
const tabId = existing?.id ?? (await openConnectPopup(relayUrl));
pendingConnectFlow = { relayUrl, tabId, notify: null };
setDrawerEnabled(false);
return { accepted: true };
return { accepted: true, confirmationRequired: true };
}
async function openConnectPopup(relayUrl: string): Promise<number | null> {
@@ -427,8 +452,10 @@ async function connectToRelay(
return { success: false, error: 'Refusing to connect: not a recognized n8n instance.' };
}
// Clean up existing connection
// Clean up existing connection, then claim a generation — `disconnect` advances it, so
// taking ours first would make this attempt invalidate itself.
disconnect();
const generation = ++connectGeneration;
try {
const ws = new WebSocket(buildRelayWsUrl(relayUrl, chrome.runtime.getManifest().version));
@@ -461,16 +488,25 @@ async function connectToRelay(
throw error;
}
if (generation !== connectGeneration) {
log.debug('discarding superseded relay connection:', relayUrl);
relay.close('superseded');
return { success: false, error: 'Superseded by a newer connection request.' };
}
activeConnection = { relay, relayUrl };
relay.onclose = () => {
log.debug('relay connection closed');
// A superseded relay closing must not clear the session that replaced it.
if (activeConnection?.relay !== relay) return;
activeConnection = null;
updateBadge(0);
broadcastStatusChange();
};
relay.ontabcreated = () => {
if (activeConnection?.relay !== relay) return;
broadcastStatusChange();
updateBadge(relay.getControlledIds().length);
};
@@ -479,7 +515,9 @@ async function connectToRelay(
log.debug('connected, controlling', tabCount, 'tabs');
updateBadge(tabCount);
broadcastStatusChange();
settleConnectFlow(pendingConnectFlow?.relayUrl === relayUrl);
// Only our own flow: a newer request may already own the pending one, and settling it
// here would fail a page whose confirmation is still on screen.
if (pendingConnectFlow?.relayUrl === relayUrl) settleConnectFlow(true);
return { success: true };
} catch (error) {
log.error('connectToRelay failed:', error);
@@ -491,6 +529,9 @@ async function connectToRelay(
}
function disconnect(): void {
// Outside the guard below: a handshake that has not committed yet leaves
// `activeConnection` null, and it must still be invalidated by a teardown.
connectGeneration++;
if (activeConnection) {
log.debug('disconnecting');
activeConnection.relay.close('extension_disconnected');
@@ -514,7 +555,8 @@ function broadcastStatusChange(): void {
// ---------------------------------------------------------------------------
function updateBadge(tabCount: number): void {
const text = tabCount > 0 ? String(tabCount) : '';
// A prompt-free connect shows no UI at all, so mark the icon even before any tab attaches.
const text = tabCount > 0 ? String(tabCount) : activeConnection ? '•' : '';
void chrome.action.setBadgeText({ text });
void chrome.action.setBadgeBackgroundColor({ color: tabCount > 0 ? '#4CAF50' : '#999' });
}
@@ -1,5 +1,5 @@
import {
getRelayHost,
getRelayHostKey,
isAllowedPageOrigin,
isAllowedRelayUrl,
isLocalhostRelay,
@@ -19,6 +19,8 @@ describe('isAllowedRelayUrl', () => {
it('allows localhost relays for local development', () => {
expect(isAllowedRelayUrl('ws://localhost:5680/browser-use/cdp/s')).toBe(true);
// Plaintext is loopback-only, so a stored approval can't be spent over one.
expect(isAllowedRelayUrl('ws://acme.app.n8n.cloud/x')).toBe(false);
expect(isAllowedRelayUrl('ws://127.0.0.1:5680/x')).toBe(true);
expect(isAllowedRelayUrl('ws://[::1]:5680/x')).toBe(true);
});
@@ -85,14 +87,20 @@ describe('isLocalhostRelay', () => {
});
});
describe('getRelayHost', () => {
it('returns the hostname for a valid URL', () => {
expect(getRelayHost('wss://acme.app.n8n.cloud/x')).toBe('acme.app.n8n.cloud');
describe('getRelayHostKey', () => {
it('keeps the port so two local instances stay distinct', () => {
expect(getRelayHostKey('ws://localhost:5678/x')).toBe('localhost:5678');
expect(getRelayHostKey('ws://localhost:5679/x')).toBe('localhost:5679');
});
it('omits the port when it is the default for the protocol', () => {
expect(getRelayHostKey('wss://acme.app.n8n.cloud/x')).toBe('acme.app.n8n.cloud');
expect(getRelayHostKey('wss://acme.app.n8n.cloud:443/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();
expect(getRelayHostKey('not a url')).toBeNull();
expect(getRelayHostKey(null)).toBeNull();
expect(getRelayHostKey(undefined)).toBeNull();
});
});
@@ -2,40 +2,44 @@
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 {
function parseRelayUrl(url: string | null | undefined): URL | null {
if (!url) return null;
try {
return new URL(url).hostname;
return new URL(url);
} catch {
return null;
}
}
function getHostname(url: string | null | undefined): string | null {
return parseRelayUrl(url)?.hostname ?? null;
}
/**
* The identity of a relay everywhere it is stored or shown: hostname plus port, with the
* protocol's default port omitted. Two local instances stay distinct; cloud hosts read
* unchanged.
*/
export function getRelayHostKey(url: string | null | undefined): string | null {
return parseRelayUrl(url)?.host ?? null;
}
export function isLocalhostRelay(url: string | null | undefined): boolean {
const host = getRelayHost(url);
const host = getHostname(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;
return isAllowedHost(parsed.hostname);
const parsed = parseRelayUrl(url);
if (!parsed) return false;
if (parsed.protocol === 'wss:') return isAllowedHost(parsed.hostname);
// Plaintext only where there is no network to intercept.
return parsed.protocol === 'ws:' && LOCAL_HOSTS.has(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;
}
const parsed = parseRelayUrl(origin);
if (!parsed) return false;
if (parsed.protocol === 'https:') return isAllowedHost(parsed.hostname);
return parsed.protocol === 'http:' && LOCAL_HOSTS.has(parsed.hostname);
}
@@ -77,6 +77,12 @@ export type ExternalMessage = ExternalConnectMessage | ExternalConnectResultMess
export interface ExternalConnectResponse {
accepted: boolean;
/**
* False when the host was already allowed and no confirmation was shown, so the page can
* say "connecting" rather than point at a popup that never appears. Older extensions
* omit it — treat as true.
*/
confirmationRequired?: boolean;
}
export interface ExternalConnectResultResponse {
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { N8nButton, N8nIcon, N8nLogo } from '@n8n/design-system';
import { N8nButton, N8nCheckbox, N8nIcon, N8nLogo } from '@n8n/design-system';
import { useConnection } from './composables/useConnection';
import InfoRow from './components/InfoRow.vue';
import RememberedHosts from './components/RememberedHosts.vue';
import TabList from './components/TabList.vue';
const {
@@ -11,14 +12,17 @@ const {
selectedTabIds,
errorMessage,
hasRelayUrl,
relayHost,
isRelayAllowed,
isAutoConnect,
relayHostKey,
rememberInstance,
approvedHosts,
controlledTabs,
toggleTab,
connect,
decline,
disconnect,
forgetHost,
} = useConnection();
const showTabSelection = ref(false);
@@ -40,7 +44,9 @@ const showConnectPrompt = computed(() => hasRelayUrl.value && isRelayAllowed.val
<div class="panel">
<InfoRow
icon="shield"
:title="relayHost ? `Connected to ${relayHost}` : 'Connected to your n8n instance'"
:title="
relayHostKey ? `Connected to ${relayHostKey}` : 'Connected to your n8n instance'
"
/>
<InfoRow
icon="lock"
@@ -52,6 +58,7 @@ const showConnectPrompt = computed(() => hasRelayUrl.value && isRelayAllowed.val
<TabList :tabs="controlledTabs" />
</template>
</div>
<RememberedHosts :hosts="approvedHosts" @forget="forgetHost" />
</template>
<template v-else-if="showConnectPrompt">
@@ -60,13 +67,20 @@ const showConnectPrompt = computed(() => hasRelayUrl.value && isRelayAllowed.val
<div class="panel">
<InfoRow
icon="shield"
:title="`Connecting to ${relayHost}`"
:title="`Connecting to ${relayHostKey}`"
description="Only continue if you initiated this connection"
/>
>
<N8nCheckbox
v-if="!isAutoConnect"
v-model="rememberInstance"
class="remember"
:label="`Always allow ${relayHostKey}`"
/>
</InfoRow>
<InfoRow
icon="lock"
title="Browser access"
description="n8n can access tabs it opens. Select existing tabs below to grant additional access"
description="n8n can access tabs it opens. Tabs you select below are shared for this connection only"
>
<button
v-if="tabs.length"
@@ -94,8 +108,8 @@ const showConnectPrompt = computed(() => hasRelayUrl.value && isRelayAllowed.val
<template v-else-if="hasRelayUrl">
<h1 class="title">Allow n8n to access your browser</h1>
<p class="error">
Can't connect to <strong>{{ relayHost || 'this address' }}</strong> — it isn't a valid n8n
instance.
Can't connect to <strong>{{ relayHostKey || 'this address' }}</strong> — it isn't a valid
n8n instance.
</p>
</template>
@@ -108,13 +122,14 @@ const showConnectPrompt = computed(() => hasRelayUrl.value && isRelayAllowed.val
description="Initiate the connection from your n8n instance to get started"
/>
</div>
<RememberedHosts :hosts="approvedHosts" @forget="forgetHost" />
</template>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
</div>
<div v-if="isConnected" class="footer">
<N8nButton variant="ghost" size="large" @click="disconnect">Disconnect</N8nButton>
<N8nButton variant="outline" size="large" @click="disconnect">Disconnect</N8nButton>
</div>
<div v-else-if="showConnectPrompt" class="footer">
<N8nButton variant="ghost" size="large" @click="decline">Decline</N8nButton>
@@ -196,17 +211,6 @@ const showConnectPrompt = computed(() => hasRelayUrl.value && isRelayAllowed.val
margin: 0 0 var(--spacing--sm);
}
.panel {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
background: var(--background--surface);
border: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
border-radius: var(--radius--lg);
padding: var(--spacing--lg);
min-height: 0;
}
.divider {
border: none;
border-top: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
@@ -232,6 +236,11 @@ const showConnectPrompt = computed(() => hasRelayUrl.value && isRelayAllowed.val
}
}
.remember {
--checkbox--label--font-size: var(--font-size--xs);
margin-top: var(--spacing--sm);
}
.footer {
display: flex;
justify-content: flex-end;
@@ -0,0 +1,75 @@
import { mount } from '@vue/test-utils';
import { ref, reactive } from 'vue';
import App from './App.vue';
import RememberedHosts from './components/RememberedHosts.vue';
// The composable's own behaviour is covered in `composables/useConnection.test.ts`.
const state = {
status: ref<'disconnected' | 'connected' | 'connecting'>('disconnected'),
tabs: ref<chrome.tabs.Tab[]>([]),
selectedTabIds: reactive(new Set<number>()),
errorMessage: ref(''),
hasRelayUrl: ref(true),
isRelayAllowed: ref(true),
isAutoConnect: ref(false),
relayHostKey: ref<string | null>('localhost:5678'),
rememberInstance: ref(false),
approvedHosts: ref<string[]>([]),
controlledTabs: ref<chrome.tabs.Tab[]>([]),
toggleTab: vi.fn(),
connect: vi.fn(),
decline: vi.fn(),
disconnect: vi.fn(),
forgetHost: vi.fn(),
};
vi.mock('./composables/useConnection', () => ({ useConnection: () => state }));
beforeEach(() => {
vi.clearAllMocks();
state.status.value = 'disconnected';
state.hasRelayUrl.value = true;
state.isRelayAllowed.value = true;
state.isAutoConnect.value = false;
state.rememberInstance.value = false;
state.approvedHosts.value = [];
});
describe('connect prompt', () => {
it('leaves the allow-always choice unticked, so it is never granted by inaction', () => {
const wrapper = mount(App);
expect(wrapper.text()).toContain('Always allow localhost:5678');
expect(state.rememberInstance.value).toBe(false);
});
it('hides the choice when connecting unattended, which must not record consent', () => {
state.isAutoConnect.value = true;
expect(mount(App).text()).not.toContain('Always allow');
});
});
// What the child renders is its own spec; App owns where it appears and the wiring.
describe('remembered hosts', () => {
it('can be reviewed while nothing is connected', () => {
state.hasRelayUrl.value = false;
state.approvedHosts.value = ['acme.app.n8n.cloud'];
expect(mount(App).findComponent(RememberedHosts).props('hosts')).toEqual([
'acme.app.n8n.cloud',
]);
});
it('can be revoked while connected to a different instance', async () => {
state.status.value = 'connected';
state.approvedHosts.value = ['localhost:5678'];
const wrapper = mount(App);
wrapper.findComponent(RememberedHosts).vm.$emit('forget', 'localhost:5678');
await wrapper.vm.$nextTick();
expect(state.forgetHost).toHaveBeenCalledWith('localhost:5678');
});
});
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { N8nIcon } from '@n8n/design-system';
import InfoRow from './InfoRow.vue';
defineProps<{ hosts: string[] }>();
defineEmits<{ forget: [host: string] }>();
</script>
<template>
<div v-if="hosts.length" class="panel">
<InfoRow
icon="badge-check"
title="Allowed instances"
description="These instances connect without asking"
>
<ul class="host-list">
<li v-for="host in hosts" :key="host" class="host">
<span class="host-name">{{ host }}</span>
<button
class="host-remove"
:title="`Ask before connecting to ${host}`"
:aria-label="`Ask before connecting to ${host}`"
@click="$emit('forget', host)"
>
<N8nIcon icon="x" size="small" />
</button>
</li>
</ul>
</InfoRow>
</div>
</template>
<style scoped lang="scss">
.host-list {
list-style: none;
margin: var(--spacing--sm) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.host {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--xs);
border: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
border-radius: var(--radius);
}
.host-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size--xs);
color: var(--color--text--shade-1);
}
.host-remove {
appearance: none;
flex-shrink: 0;
display: flex;
align-items: center;
padding: var(--spacing--4xs);
background: transparent;
border: none;
border-radius: var(--radius);
cursor: pointer;
color: var(--text-color--subtler);
&:hover {
background: var(--color--background);
color: var(--color--text--shade-1);
}
}
</style>
@@ -0,0 +1,35 @@
import { mount } from '@vue/test-utils';
import RememberedHosts from './RememberedHosts.vue';
function render(hosts: string[]) {
return mount(RememberedHosts, { props: { hosts } });
}
describe('RememberedHosts', () => {
it('renders nothing when no host has been allowed', () => {
expect(render([]).text()).toBe('');
});
it('names every allowed host, so none is silently trusted', () => {
const wrapper = render(['acme.app.n8n.cloud', 'localhost:5678']);
const names = wrapper.findAll('.host-name').map((el) => el.text());
expect(names).toEqual(['acme.app.n8n.cloud', 'localhost:5678']);
});
it('revokes only the host whose control was used', async () => {
const wrapper = render(['acme.app.n8n.cloud', 'localhost:5678']);
await wrapper.findAll('.host-remove')[1].trigger('click');
expect(wrapper.emitted('forget')).toEqual([['localhost:5678']]);
});
it('labels the control for pointer and screen reader alike', () => {
const button = render(['localhost:5678']).find('.host-remove');
expect(button.attributes('title')).toBe('Ask before connecting to localhost:5678');
expect(button.attributes('aria-label')).toBe('Ask before connecting to localhost:5678');
});
});
@@ -62,6 +62,12 @@ const chromeMock = {
windows: {
getCurrent: vi.fn(),
},
storage: {
local: {
get: vi.fn(),
set: vi.fn(),
},
},
};
Object.assign(globalThis, { chrome: chromeMock });
@@ -142,6 +148,8 @@ beforeEach(() => {
chromeMock.tabs.get.mockImplementation(async (id: number) => await Promise.resolve(makeTab(id)));
chromeMock.windows.getCurrent.mockResolvedValue({ type: 'normal' } as chrome.windows.Window);
chromeMock.storage.local.get.mockResolvedValue({});
chromeMock.storage.local.set.mockResolvedValue(undefined);
});
// ---------------------------------------------------------------------------
@@ -197,7 +205,7 @@ describe('useConnection', () => {
});
describe('connected instance', () => {
const RELAY_URL = 'wss://acme.app.n8n.cloud/relay';
const RELAY_URL = 'wss://acme.app.n8n.cloud:8443/relay';
it('names the instance reported by the background on mount', async () => {
chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => {
@@ -210,7 +218,7 @@ describe('useConnection', () => {
const { wrapper, result } = mountComposable();
await flush();
expect(result().relayHost.value).toBe('acme.app.n8n.cloud');
expect(result().relayHostKey.value).toBe('acme.app.n8n.cloud:8443');
wrapper.unmount();
});
@@ -218,12 +226,12 @@ describe('useConnection', () => {
it('names the instance when a connection starts while the view is open', async () => {
const { wrapper, result } = mountComposable();
await flush();
expect(result().relayHost.value).toBeNull();
expect(result().relayHostKey.value).toBeNull();
pushMessage({ type: 'statusChanged', connected: true, tabIds: [], relayUrl: RELAY_URL });
await flush();
expect(result().relayHost.value).toBe('acme.app.n8n.cloud');
expect(result().relayHostKey.value).toBe('acme.app.n8n.cloud:8443');
wrapper.unmount();
});
@@ -237,7 +245,7 @@ describe('useConnection', () => {
await result().disconnect();
await flush();
expect(result().relayHost.value).toBeNull();
expect(result().relayHostKey.value).toBeNull();
wrapper.unmount();
});
@@ -516,6 +524,176 @@ describe('useConnection', () => {
});
});
describe('remembered instances', () => {
const APPROVED_KEY = 'approvedRelayHosts';
const RELAY_URL = 'ws://localhost:1234/ext';
/** Background responses for a connect that succeeds against RELAY_URL. */
function stubSuccessfulConnect(): void {
chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => {
if (msg.type === 'getRelayUrl') return RELAY_URL;
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({});
});
}
it('stores the host and port when the user opts in', async () => {
stubSuccessfulConnect();
const { wrapper, result } = mountComposable();
await flush();
result().rememberInstance.value = true;
await result().connect();
await flush();
expect(chromeMock.storage.local.set).toHaveBeenCalledWith({
[APPROVED_KEY]: ['localhost:1234'],
});
wrapper.unmount();
});
it('stores the host the user approved even if the live URL changes mid-handshake', async () => {
chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => {
if (msg.type === 'getRelayUrl') return RELAY_URL;
if (msg.type === 'getStatus') return { connected: false, tabIds: [] };
if (msg.type === 'getTabs') return [makeTab(1)];
if (msg.type === 'connect') {
// The session drops mid-handshake, which clears the live relay URL.
pushMessage({ type: 'statusChanged', connected: false });
return { success: true };
}
if (msg.type === 'clearRelayUrl') return { success: true };
return await Promise.resolve({});
});
const { wrapper, result } = mountComposable();
await flush();
result().rememberInstance.value = true;
await result().connect();
await flush();
expect(chromeMock.storage.local.set).toHaveBeenCalledWith({
[APPROVED_KEY]: ['localhost:1234'],
});
wrapper.unmount();
});
it('clears the opt-in when a different instance takes over the page', async () => {
const { wrapper, result } = mountComposable();
await flush();
result().rememberInstance.value = true;
pushMessage({ type: 'relayUrlReady', relayUrl: 'ws://other.host:9999/ext' });
await flush();
expect(result().rememberInstance.value).toBe(false);
wrapper.unmount();
});
it('stores nothing by default — auto-connect is opt-in', async () => {
stubSuccessfulConnect();
const { wrapper, result } = mountComposable();
await flush();
expect(result().rememberInstance.value).toBe(false);
await result().connect();
await flush();
expect(chromeMock.storage.local.set).not.toHaveBeenCalled();
wrapper.unmount();
});
it('stores nothing when auto-connecting even if remember is somehow set', async () => {
window.history.replaceState(
{},
'',
'/?autoConnect=1&mcpRelayUrl=' + encodeURIComponent(RELAY_URL),
);
stubSuccessfulConnect();
const { wrapper, result } = mountComposable();
await flush();
result().rememberInstance.value = true;
await result().connect();
await flush();
expect(chromeMock.storage.local.set).not.toHaveBeenCalled();
wrapper.unmount();
window.history.replaceState({}, '', '/');
});
it('stores nothing when auto-connecting for the eval harness', async () => {
window.history.replaceState(
{},
'',
'/?autoConnect=1&mcpRelayUrl=' + encodeURIComponent(RELAY_URL),
);
stubSuccessfulConnect();
const { wrapper, result } = mountComposable();
await flush();
expect(result().isAutoConnect.value).toBe(true);
expect(chromeMock.storage.local.set).not.toHaveBeenCalled();
wrapper.unmount();
window.history.replaceState({}, '', '/');
});
it('exposes every stored host so they can be managed while disconnected', async () => {
chromeMock.storage.local.get.mockResolvedValue({
[APPROVED_KEY]: ['acme.app.n8n.cloud', 'localhost:1234'],
});
const { wrapper, result } = mountComposable();
await flush();
expect(result().approvedHosts.value).toEqual(['acme.app.n8n.cloud', 'localhost:1234']);
wrapper.unmount();
});
it('drops a stored host without ending the session', async () => {
const stored = ['acme.app.n8n.cloud', 'localhost:1234'];
chromeMock.storage.local.get.mockImplementation(
async () => await Promise.resolve({ [APPROVED_KEY]: stored }),
);
chromeMock.storage.local.set.mockImplementation(async (update: Record<string, string[]>) => {
stored.splice(0, stored.length, ...update[APPROVED_KEY]);
return await Promise.resolve(undefined);
});
chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => {
if (msg.type === 'getRelayUrl') return null;
if (msg.type === 'getStatus') return { connected: true, tabIds: [], relayUrl: RELAY_URL };
if (msg.type === 'getTabs') return [makeTab(1)];
return await Promise.resolve({});
});
const { wrapper, result } = mountComposable();
await flush();
await result().forgetHost('localhost:1234');
expect(result().approvedHosts.value).toEqual(['acme.app.n8n.cloud']);
expect(result().status.value).toBe('connected');
expect(chromeMock.runtime.sendMessage).not.toHaveBeenCalledWith({ type: 'disconnect' });
wrapper.unmount();
});
});
describe('disconnect', () => {
it('clears status and controlled tabs', async () => {
const { wrapper, result } = mountComposable();
@@ -1,7 +1,8 @@
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue';
import { forgetApprovedHost, listApprovedHosts, rememberHost } from '../../approvedHosts';
import { createLogger } from '../../logger';
import { getRelayHost, isAllowedRelayUrl, isLocalhostRelay } from '../../relayAllowlist';
import { getRelayHostKey, isAllowedRelayUrl, isLocalhostRelay } from '../../relayAllowlist';
import { isEligibleTab } from '../../relayConnection';
import type {
ConnectionStatus,
@@ -34,6 +35,13 @@ export function useConnection() {
// attacker-controlled WSS endpoint.
const isAutoConnect = ref<boolean>(false);
// ── Remembered-instance consent ──────────────────────────────────────────
// Opt-in: a remembered instance reconnects with no prompt at all, so nobody should end
// up in that state without ticking the box themselves.
const rememberInstance = ref(false);
// Every stored host, so the drawer can revoke them without being connected.
const approvedHosts = ref<string[]>([]);
// ── Single source of truth: reactive tab registry ─────────────────────────
// Maps chromeTabId → tab object. Kept in sync by Chrome tab event listeners.
const tabRegistry = reactive(new Map<number, chrome.tabs.Tab>());
@@ -55,8 +63,10 @@ export function useConnection() {
// ── Computeds ─────────────────────────────────────────────────────────────
const hasRelayUrl = computed(() => !!relayUrl.value);
const relayHost = computed(() => getRelayHost(connectedRelayUrl.value ?? relayUrl.value));
const isRelayAllowed = computed(() => isAllowedRelayUrl(relayUrl.value));
// The one identity the user ever sees, and the one that gets stored, so what they agree
// to always matches what the revoke list shows back.
const relayHostKey = computed(() => getRelayHostKey(connectedRelayUrl.value ?? relayUrl.value));
// ── Private helpers ───────────────────────────────────────────────────────
@@ -114,24 +124,31 @@ export function useConnection() {
}
if (!isAllowedRelayUrl(relayUrl.value)) {
errorMessage.value = `Can't connect to ${relayHost.value ?? 'this address'} — not a recognized n8n instance.`;
errorMessage.value = `Can't connect to ${relayHostKey.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);
// Pinned before any await: a new request can replace `relayUrl` mid-handshake, and the
// approval must record the host the user was actually shown.
const approvedUrl = relayUrl.value;
log.debug('connect: relay URL =', approvedUrl, 'selectedTabs:', selectedTabIds.size);
status.value = 'connecting';
errorMessage.value = '';
const raw: unknown = await chrome.runtime.sendMessage({
type: 'connect',
relayUrl: relayUrl.value,
relayUrl: approvedUrl,
selectedTabIds: [...selectedTabIds],
});
log.debug('connect response:', raw);
if (isConnectResponse(raw) && raw.success) {
status.value = 'connected';
// The eval harness connects unattended — it must not write user-facing trust state.
if (rememberInstance.value && !isAutoConnect.value) {
approvedHosts.value = await rememberHost(approvedUrl);
}
await chrome.runtime.sendMessage({ type: 'clearRelayUrl' });
// Fetch controlled IDs — controlledTabDetails computed auto-resolves from registry
const statusResponse: unknown = await chrome.runtime.sendMessage({ type: 'getStatus' });
@@ -158,6 +175,12 @@ export function useConnection() {
relayUrl.value = null;
}
/** Drops a stored approval. Never touches the live session. */
async function forgetHost(host: string): Promise<void> {
log.debug('forgetHost', host);
approvedHosts.value = await forgetApprovedHost(host);
}
async function decline(): Promise<void> {
log.debug('decline');
await chrome.runtime.sendMessage({ type: 'clearRelayUrl' });
@@ -185,6 +208,8 @@ export function useConnection() {
if (message.type === 'relayUrlReady' && message.relayUrl) {
log.debug('relayUrlReady received:', message.relayUrl);
relayUrl.value = message.relayUrl;
// A different instance is asking now, so its approval has to be given afresh.
rememberInstance.value = false;
// 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);
@@ -253,7 +278,8 @@ export function useConnection() {
applyStatus(currentStatus);
}
await initTabRegistry();
const [storedHosts] = await Promise.all([listApprovedHosts(), initTabRegistry()]);
approvedHosts.value = storedHosts;
if (!mounted) return;
@@ -282,13 +308,16 @@ export function useConnection() {
errorMessage,
relayUrl,
hasRelayUrl,
relayHost,
isRelayAllowed,
isAutoConnect,
relayHostKey,
rememberInstance,
approvedHosts,
controlledTabs: controlledTabDetails,
toggleTab,
connect,
decline,
disconnect,
forgetHost,
};
}
@@ -37,3 +37,19 @@ body {
background-color: var(--color--background);
color: var(--color--text--shade-1);
}
// Grouping box shared by the views and the allowed-instances list.
.panel {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
background: var(--background--surface);
border: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
border-radius: var(--radius--lg);
padding: var(--spacing--lg);
min-height: 0;
}
.panel + .panel {
margin-top: var(--spacing--md);
}
@@ -1,3 +1,9 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig, mergeConfig } from 'vitest/config';
import { createVitestConfig } from '@n8n/vitest-config/frontend';
export default createVitestConfig({ setupFiles: [] });
// The UI is Vue SFCs, so the plugin has to be present for `.vue` imports to parse.
export default mergeConfig(
defineConfig({ plugins: [vue()] }),
createVitestConfig({ setupFiles: [] }),
);
@@ -6860,6 +6860,7 @@
"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.connecting": "Connecting to your browser…",
"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.",
@@ -5,13 +5,13 @@ import {
useInstanceAiInputMenuItems,
} from '../composables/useInstanceAiInputMenuItems';
import {
INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY,
INSTANCE_AI_COMPUTER_USE_SETUP_MODAL_KEY,
INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY,
} from '../constants';
const {
browserUseTelemetry,
ensureBrowserConnected,
computerUseTelemetry,
featureFlags,
mcpStore,
@@ -20,6 +20,7 @@ const {
uiStore,
} = vi.hoisted(() => ({
browserUseTelemetry: { trackModalOpened: vi.fn() },
ensureBrowserConnected: vi.fn(),
computerUseTelemetry: { trackModalOpened: vi.fn() },
featureFlags: { browserUse: true, computerUse: true, mcp: true },
mcpStore: {
@@ -70,6 +71,9 @@ vi.mock('@/experiments/instanceAiMcpConnections', () => ({
}),
}));
vi.mock('../composables/useBrowserUseConnection', () => ({
useBrowserUseConnection: () => ({ ensureConnected: ensureBrowserConnected }),
}));
vi.mock('@/experiments/instanceAiBrowserUse', () => ({
useInstanceAiBrowserUseExperiment: () => ({
isFeatureEnabled: {
@@ -200,7 +204,9 @@ describe('useInstanceAiInputMenuItems', () => {
setDisconnected: () => {
settingsStore.browserUseConnectionStatus = 'disconnected';
},
modal: INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY,
// Browser Use reconnects through the shared flow, which opens a modal only when
// the extension actually needs the user.
modal: null,
title: 'instanceAi.inputMenu.browser.connectedTitle',
},
])(
@@ -216,7 +222,12 @@ describe('useInstanceAiInputMenuItems', () => {
expect(disconnectedConnectionCount.value).toBe(1);
await findItem(menuItems.value, `${id}-reconnect`)?.data?.action?.();
expect(uiStore.openModal).toHaveBeenCalledWith(modal);
if (modal) {
expect(uiStore.openModal).toHaveBeenCalledWith(modal);
} else {
expect(ensureBrowserConnected).toHaveBeenCalledWith('input_menu');
expect(uiStore.openModal).not.toHaveBeenCalled();
}
},
);
@@ -282,7 +293,6 @@ describe('useInstanceAiInputMenuItems', () => {
});
expect(mcpStore.disconnect).toHaveBeenCalledWith('1');
expect(settingsStore.disconnectComputerUse).toHaveBeenCalledOnce();
expect(browserUseTelemetry.trackModalOpened).toHaveBeenCalledWith('input_menu');
expect(uiStore.openModal).toHaveBeenCalledWith(INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY);
expect(ensureBrowserConnected).toHaveBeenCalledWith('input_menu');
});
});
@@ -1,5 +1,4 @@
<script lang="ts" setup>
import { INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY } from '../constants';
import { useUIStore } from '@/app/stores/ui.store';
import { getAppNameFromCredType } from '@/app/utils/nodeTypesUtils';
import { useInstanceAiBrowserCredentialSetupExperiment } from '@/experiments/instanceAiBrowserCredentialSetup';
@@ -25,9 +24,9 @@ import { v4 as uuidv4 } from 'uuid';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { useInstanceAiBrowserUseTelemetry } from '../instanceAiBrowserUse.telemetry';
import { useThread } from '../instanceAi.store';
import { useInstanceAiCredentialHelp } from '../composables/useInstanceAiCredentialHelp';
import { useBrowserUseConnection } from '../composables/useBrowserUseConnection';
import ConfirmationFooter from './ConfirmationFooter.vue';
type CredentialSetupChoice = 'ai' | 'manual';
@@ -43,11 +42,11 @@ const props = defineProps<{
const i18n = useI18n();
const telemetry = useTelemetry();
const browserUseTelemetry = useInstanceAiBrowserUseTelemetry();
const rootStore = useRootStore();
const thread = useThread();
const credentialsStore = useCredentialsStore();
const uiStore = useUIStore();
const { ensureConnected: ensureBrowserConnected } = useBrowserUseConnection();
const settingsStore = useInstanceAiSettingsStore();
const { isFeatureEnabled: isBrowserCredentialSetupEnabled } =
@@ -129,7 +128,6 @@ const stopCreateListener = credentialsStore.$onAction(({ name, after }) => {
onBeforeUnmount(() => {
stopDeleteListener();
stopCreateListener();
stopWatchingBrowserConnect();
});
// ---------------------------------------------------------------------------
@@ -542,22 +540,6 @@ watch(
{ immediate: true },
);
let stopBrowserConnectWatch: (() => void) | undefined;
function stopWatchingBrowserConnect() {
stopBrowserConnectWatch?.();
stopBrowserConnectWatch = undefined;
}
watch(
() => uiStore.modalsById[INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY]?.open,
(isOpen, wasOpen) => {
if (wasOpen && !isOpen && !settingsStore.browserConnected) {
stopWatchingBrowserConnect();
}
},
);
function onSetupChoiceSelected(choice: CredentialSetupChoice) {
if (choice === 'ai') {
void handleSetupAutomatically();
@@ -592,23 +574,8 @@ async function handleSetupAutomatically() {
const attemptId = uuidv4();
trackSetupChoiceClicked('ai', attemptId);
if (settingsStore.browserConnected) {
await submitAutoSetup(credentialType, attemptId);
return;
}
browserUseTelemetry.trackModalOpened('credential_setup');
uiStore.openModal(INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY);
stopWatchingBrowserConnect();
stopBrowserConnectWatch = watch(
() => settingsStore.browserConnected,
async (connected) => {
if (!connected) return;
stopWatchingBrowserConnect();
uiStore.closeModal(INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY);
await submitAutoSetup(credentialType, attemptId);
},
);
if (!(await ensureBrowserConnected('credential_setup'))) return;
await submitAutoSetup(credentialType, attemptId);
}
</script>
@@ -1,10 +1,13 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
import { useInstanceAiBrowserUseTelemetry } from '../../instanceAiBrowserUse.telemetry';
import { useExtensionDirectConnect } from '../../composables/useExtensionDirectConnect';
import {
resetExtensionDirectConnect,
useExtensionDirectConnect,
} from '../../composables/useExtensionDirectConnect';
const CONNECT_URL_REFRESH_MARGIN_MS = 30_000;
const CONNECT_POPUP_WIDTH = 540;
@@ -15,9 +18,19 @@ const props = withDefaults(defineProps<{ autoConnect?: boolean }>(), { autoConne
const i18n = useI18n();
const store = useInstanceAiSettingsStore();
const telemetry = useInstanceAiBrowserUseTelemetry();
const { status, attempt } = useExtensionDirectConnect();
const { status, isFlowActive, attempt } = useExtensionDirectConnect();
const connectUrl = ref<string | null>(null);
// A remembered host attaches with no popup, so it must not be told to confirm one.
const inFlightTextKey = computed(() => {
if (status.value === 'waiting') return 'instanceAi.browserUse.directConnect.waiting';
// `connected` keeps the spinner up until the parent swaps to its connected view, which
// happens on a separate push — otherwise the connect action flashes back in between.
if (status.value === 'connecting' || status.value === 'connected') {
return 'instanceAi.browserUse.directConnect.connecting';
}
return null;
});
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
function clearRefreshTimer(): void {
@@ -27,9 +40,20 @@ function clearRefreshTimer(): void {
}
}
/**
* Minting rotates the relay token server-side, killing any connect already in flight — so
* reuse a stored link while it has life left in it.
*/
function usableStoredConnectUrl(): string | null {
const url = store.browserConnectUrl;
const expiresAt = store.browserConnectUrlExpiresAt;
if (!url || !expiresAt) return null;
return Date.parse(expiresAt) - Date.now() > CONNECT_URL_REFRESH_MARGIN_MS ? url : null;
}
async function refreshConnectUrl(): Promise<void> {
clearRefreshTimer();
connectUrl.value = await store.fetchBrowserConnectUrl();
connectUrl.value = usableStoredConnectUrl() ?? (await store.fetchBrowserConnectUrl());
const expiresAt = store.browserConnectUrlExpiresAt;
if (!connectUrl.value || !expiresAt) return;
@@ -43,17 +67,30 @@ async function refreshConnectUrl(): Promise<void> {
}
onMounted(async () => {
// Read before any await: an outer flow can settle while the URL is being fetched, and
// both decisions below must reflect the state at the moment this view opened.
const joinedOuterFlow = isFlowActive.value;
// Don't inherit the leftover status of a flow that already finished.
if (!joinedOuterFlow) resetExtensionDirectConnect();
await refreshConnectUrl();
if (!props.autoConnect || !connectUrl.value) return;
// The outer flow is already driving this; render its status rather than re-requesting.
if (joinedOuterFlow) return;
telemetry.trackDirectConnectRequested();
await attempt(connectUrl.value);
});
async function retry(): Promise<void> {
if (!connectUrl.value) await refreshConnectUrl();
/**
* Let the extension own the confirmation first — a remembered instance connects with no
* window at all. The unreachable case resolves fast enough that the click's transient
* activation still permits `window.open`.
*/
async function connect(): Promise<void> {
if (!connectUrl.value) return;
telemetry.trackDirectConnectRequested();
await attempt(connectUrl.value);
if (status.value === 'unsupported') openConnectPage();
}
function openConnectPage(): void {
@@ -87,13 +124,13 @@ onBeforeUnmount(() => {
</N8nText>
<div
v-if="status === 'waiting'"
v-if="inFlightTextKey"
:class="$style.waiting"
data-test-id="browser-use-direct-connect-waiting"
>
<N8nIcon icon="spinner" color="primary" spin size="small" />
<N8nText color="text-light" size="small">
{{ i18n.baseText('instanceAi.browserUse.directConnect.waiting') }}
{{ i18n.baseText(inFlightTextKey) }}
</N8nText>
</div>
@@ -106,7 +143,7 @@ onBeforeUnmount(() => {
variant="solid"
size="medium"
data-test-id="browser-use-direct-connect-retry"
@click="retry"
@click="connect"
/>
</template>
@@ -120,7 +157,7 @@ onBeforeUnmount(() => {
size="medium"
:disabled="!connectUrl"
data-test-id="browser-use-open-connect-page"
@click="openConnectPage"
@click="connect"
/>
</template>
</div>
@@ -1,8 +1,7 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useEventListener } from '@vueuse/core';
import { N8nButton, N8nCallout, N8nHeading, N8nText } from '@n8n/design-system';
import { useToast } from '@n8n/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { isBrowserUseSupportedForBrowser } from '@/experiments/instanceAiBrowserUse';
import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
@@ -30,7 +29,6 @@ const props = withDefaults(
const emit = defineEmits<{ close: [] }>();
const i18n = useI18n();
const toast = useToast();
const store = useInstanceAiSettingsStore();
const telemetry = useInstanceAiBrowserUseTelemetry();
const { onDocumentVisible } = useDocumentVisibility();
@@ -61,24 +59,6 @@ onMounted(async () => {
statusChecked.value = true;
});
// When the connection is driven from this view, reporting success as a toast keeps
// the screen free instead of parking the user on a status-only modal. Flushed
// synchronously because other views watch the same state to close this one, and a
// deferred callback would be dropped along with the unmounting component.
watch(
isConnected,
(connected) => {
if (!connected || !props.autoConnect) return;
toast.showMessage({
type: 'success',
title: i18n.baseText('instanceAi.browserUse.connected'),
message: i18n.baseText('instanceAi.browserUse.connected.toastMessage'),
});
emit('close');
},
{ flush: 'sync' },
);
// Re-probe when the user returns from installing the extension. Coming back by tab switch
// only fires `visibilitychange` — the window's focus can land in DevTools or another pane —
// while coming back from a separate window only fires `focus`, so we listen for both.
@@ -3,6 +3,7 @@ import { fireEvent } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { createComponentRenderer } from '@/__tests__/render';
import BrowserUseConnectStep from '../BrowserUseConnectStep.vue';
import { resetExtensionDirectConnect } from '../../../composables/useExtensionDirectConnect';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
@@ -34,6 +35,7 @@ const CONNECT_URL = `chrome-extension://testextensionid/connect.html?mcpRelayUrl
function makeSettingsStore(overrides: Record<string, unknown> = {}) {
return {
browserConnectUrl: null,
browserConnectUrlExpiresAt: null,
fetchBrowserConnectUrl: vi.fn().mockResolvedValue(CONNECT_URL),
clearBrowserConnectUrl: vi.fn(),
@@ -63,6 +65,7 @@ const renderComponent = createComponentRenderer(BrowserUseConnectStep, {
describe('BrowserUseConnectStep', () => {
beforeEach(() => {
vi.clearAllMocks();
resetExtensionDirectConnect();
settingsStoreMock.mockReturnValue(makeSettingsStore());
});
@@ -107,6 +110,30 @@ describe('BrowserUseConnectStep', () => {
expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(1);
});
it('does not point at a popup when the host connects without one', async () => {
installExtensionMock({ connect: { accepted: true, confirmationRequired: false } });
const { getByTestId } = renderComponent();
await flushPromises();
const status = getByTestId('browser-use-direct-connect-waiting');
expect(status).toHaveTextContent('instanceAi.browserUse.directConnect.connecting');
expect(status).not.toHaveTextContent('instanceAi.browserUse.directConnect.waiting');
});
it('does not inherit the status of a flow that already finished', async () => {
// A successful connect leaves the shared status at 'waiting'.
installExtensionMock({ connect: { accepted: true }, connectResult: { connected: true } });
const first = renderComponent();
await flushPromises();
first.unmount();
const { getByTestId, queryByTestId } = createComponentRenderer(BrowserUseConnectStep)();
await flushPromises();
expect(getByTestId('browser-use-open-connect-page')).toBeVisible();
expect(queryByTestId('browser-use-direct-connect-waiting')).toBeNull();
});
it('offers a retry when the connect did not succeed', async () => {
installExtensionMock({ connect: { accepted: true }, connectResult: { connected: false } });
const { getByTestId } = renderComponent();
@@ -120,6 +147,7 @@ describe('BrowserUseConnectStep', () => {
it('shows the manual connect link when the extension stops responding on retry', async () => {
installExtensionMock({ connect: { accepted: true }, connectResult: { connected: false } });
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
const { getByTestId } = renderComponent();
await flushPromises();
@@ -128,9 +156,27 @@ describe('BrowserUseConnectStep', () => {
await flushPromises();
expect(getByTestId('browser-use-open-connect-page')).toBeVisible();
openSpy.mockRestore();
});
it('opens the connect page as a popup on the manual connect button', async () => {
it('lets the extension own the confirmation instead of opening a window', async () => {
installExtensionMock({ connect: { accepted: true } });
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
const { getByTestId } = createComponentRenderer(BrowserUseConnectStep)();
await flushPromises();
await fireEvent.click(getByTestId('browser-use-open-connect-page'));
await flushPromises();
expect(openSpy).not.toHaveBeenCalled();
expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(1);
expect(getByTestId('browser-use-direct-connect-waiting')).toBeVisible();
openSpy.mockRestore();
});
it('falls back to opening the connect page when the extension cannot be messaged', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
const { getByTestId } = renderComponent();
await flushPromises();
@@ -147,6 +193,35 @@ describe('BrowserUseConnectStep', () => {
openSpy.mockRestore();
});
it('reuses a live connect URL rather than minting one', async () => {
// Minting rotates the relay token, killing a connect an outer caller already started.
const store = makeSettingsStore({
browserConnectUrl: CONNECT_URL,
browserConnectUrlExpiresAt: new Date(Date.now() + 600_000).toISOString(),
});
settingsStoreMock.mockReturnValue(store);
installExtensionMock({ connect: { accepted: true } });
const { getByTestId } = renderComponent();
await flushPromises();
expect(store.fetchBrowserConnectUrl).not.toHaveBeenCalled();
expect(getByTestId('browser-use-direct-connect-waiting')).toBeVisible();
});
it('mints a connect URL when the stored one is about to expire', async () => {
const store = makeSettingsStore({
browserConnectUrl: CONNECT_URL,
browserConnectUrlExpiresAt: new Date(Date.now() + 5_000).toISOString(),
});
settingsStoreMock.mockReturnValue(store);
renderComponent();
await flushPromises();
expect(store.fetchBrowserConnectUrl).toHaveBeenCalledTimes(1);
});
it('clears the stored connect URL when unmounted', async () => {
const store = makeSettingsStore();
settingsStoreMock.mockReturnValue(store);
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fireEvent } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { defineComponent, h, reactive, ref, watch } from 'vue';
import { reactive } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import BrowserUseSetupContent from '../BrowserUseSetupContent.vue';
import BrowserUseSetupModal from '../BrowserUseSetupModal.vue';
@@ -230,17 +230,8 @@ describe('BrowserUseSetupModal', () => {
return rendered;
}
it('closes the view and reports the success as a toast when auto-connecting', async () => {
const { emitted } = await renderContentAndConnect({ autoConnect: true });
expect(emitted('close')).toHaveLength(1);
expect(showMessageMock).toHaveBeenCalledWith({
type: 'success',
title: 'instanceAi.browserUse.connected',
message: 'instanceAi.browserUse.connected.toastMessage',
});
});
// Closing the modal and reporting success now belong to useBrowserUseConnection, which
// drives every connect — including the remembered one that never opens this view.
it('keeps the connected status in place when not auto-connecting', async () => {
const { emitted, getByText } = await renderContentAndConnect({ embedded: true });
@@ -248,34 +239,6 @@ describe('BrowserUseSetupModal', () => {
expect(showMessageMock).not.toHaveBeenCalled();
expect(getByText('instanceAi.browserUse.connected')).toBeVisible();
});
// The credential setup flow watches the same connection state and closes the modal
// itself, so the toast has to be reported before this view is torn down.
it('reports the success even when an outside watcher closes the view first', async () => {
const store = reactive(makeSettingsStore());
settingsStoreMock.mockReturnValue(store);
const host = defineComponent({
setup() {
const visible = ref(true);
watch(
() => store.browserConnected,
(connected) => {
if (connected) visible.value = false;
},
);
return () => (visible.value ? h(BrowserUseSetupContent, { autoConnect: true }) : null);
},
});
createComponentRenderer(host)();
await flushPromises();
store.browserConnected = true;
await flushPromises();
expect(showMessageMock).toHaveBeenCalledTimes(1);
});
});
it('keeps the install step visible while the connect step waits for confirmation', async () => {
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { reactive, ref } from 'vue';
import { useBrowserUseConnection } from '../useBrowserUseConnection';
import { INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY as MODAL_KEY } from '../../constants';
const CONNECT_URL = 'chrome-extension://testextensionid/connect.html?mcpRelayUrl=wss%3A%2F%2Fx';
const settingsStore = reactive({
browserConnected: false,
fetchBrowserConnectUrl: vi.fn<() => Promise<string | null>>(),
});
const uiStore = reactive({
activeModals: [] as string[],
openModal: vi.fn((key: string) => {
uiStore.activeModals.push(key);
}),
closeModal: vi.fn((key: string) => {
uiStore.activeModals = uiStore.activeModals.filter((name) => name !== key);
}),
});
const telemetryMock = { trackDirectConnectRequested: vi.fn(), trackModalOpened: vi.fn() };
const toastMock = { showMessage: vi.fn() };
const attemptMock = vi.fn<(connectUrl: string) => Promise<void>>();
const directConnectStatus = ref<'idle' | 'unsupported' | 'waiting' | 'connecting' | 'failed'>(
'idle',
);
const isAttempting = ref(false);
/** Mirrors the real composable: the flag flips synchronously when a flow starts. */
async function attemptDouble(connectUrl: string): Promise<void> {
isAttempting.value = true;
directConnectStatus.value = 'idle';
try {
await attemptMock(connectUrl);
} finally {
isAttempting.value = false;
}
}
vi.mock('../../instanceAiSettings.store', () => ({
useInstanceAiSettingsStore: () => settingsStore,
}));
// Keep the real `listenForModalChanges` — the double only stands in for the store.
vi.mock('@/app/stores/ui.store', async (importOriginal) => ({
...(await importOriginal<typeof import('@/app/stores/ui.store')>()),
useUIStore: () => uiStore,
}));
vi.mock('../../instanceAiBrowserUse.telemetry', () => ({
useInstanceAiBrowserUseTelemetry: () => telemetryMock,
}));
vi.mock('../useExtensionDirectConnect', () => ({
beginConnectFlow: () => () => {},
useExtensionDirectConnect: () => ({
status: directConnectStatus,
isAttempting,
attempt: attemptDouble,
}),
}));
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => toastMock }));
vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => key }) }));
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
settingsStore.browserConnected = false;
settingsStore.fetchBrowserConnectUrl.mockResolvedValue(CONNECT_URL);
attemptMock.mockResolvedValue(undefined);
directConnectStatus.value = 'idle';
isAttempting.value = false;
uiStore.activeModals = [];
});
afterEach(() => {
vi.useRealTimers();
});
describe('useBrowserUseConnection', () => {
it('resolves immediately when the browser is already connected', async () => {
settingsStore.browserConnected = true;
await expect(useBrowserUseConnection().ensureConnected('input_menu')).resolves.toBe(true);
expect(uiStore.openModal).not.toHaveBeenCalled();
expect(attemptMock).not.toHaveBeenCalled();
// Nothing changed, so there is nothing to announce.
expect(toastMock.showMessage).not.toHaveBeenCalled();
});
it('skips the modal when the extension reports it needs no confirmation', async () => {
attemptMock.mockImplementation(async () => {
directConnectStatus.value = 'connecting';
settingsStore.browserConnected = true;
});
const result = useBrowserUseConnection().ensureConnected('input_menu');
await vi.advanceTimersByTimeAsync(0);
await expect(result).resolves.toBe(true);
// The flash this whole path exists to avoid.
expect(uiStore.openModal).not.toHaveBeenCalled();
expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(1);
// Without the modal there is nothing else on screen to confirm the connection.
expect(toastMock.showMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
);
});
it('falls back to the setup modal when the extension asks for confirmation', async () => {
attemptMock.mockImplementation(async () => {
directConnectStatus.value = 'waiting';
});
const result = useBrowserUseConnection().ensureConnected('input_menu');
await vi.advanceTimersByTimeAsync(0);
expect(uiStore.openModal).toHaveBeenCalledWith(MODAL_KEY);
settingsStore.browserConnected = true;
await vi.advanceTimersByTimeAsync(0);
await expect(result).resolves.toBe(true);
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_KEY);
expect(toastMock.showMessage).toHaveBeenCalledTimes(1);
});
it('resolves when the browser attaches before the modal listeners are set up', async () => {
// The extension asked for confirmation and the user approved it fast, so the state is
// already true by the time the modal opens — a plain watch would never fire.
attemptMock.mockImplementation(async () => {
directConnectStatus.value = 'waiting';
settingsStore.browserConnected = true;
});
const result = useBrowserUseConnection().ensureConnected('input_menu');
await vi.advanceTimersByTimeAsync(0);
await expect(result).resolves.toBe(true);
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_KEY);
});
it('reports failure when the user dismisses the modal', async () => {
attemptMock.mockImplementation(async () => {
directConnectStatus.value = 'waiting';
});
const result = useBrowserUseConnection().ensureConnected('input_menu');
await vi.advanceTimersByTimeAsync(0);
uiStore.closeModal(MODAL_KEY);
await vi.advanceTimersByTimeAsync(0);
await expect(result).resolves.toBe(false);
expect(toastMock.showMessage).not.toHaveBeenCalled();
});
it('does not mint a second link while a connect is already running', async () => {
isAttempting.value = true;
directConnectStatus.value = 'waiting';
const result = useBrowserUseConnection().ensureConnected('input_menu');
await vi.advanceTimersByTimeAsync(0);
// Minting rotates the relay token and would strand the running connect.
expect(settingsStore.fetchBrowserConnectUrl).not.toHaveBeenCalled();
expect(attemptMock).not.toHaveBeenCalled();
expect(uiStore.openModal).toHaveBeenCalledWith(MODAL_KEY);
uiStore.closeModal(MODAL_KEY);
await vi.advanceTimersByTimeAsync(0);
await expect(result).resolves.toBe(false);
});
it('starts a clean flow when an earlier one already failed', async () => {
// A stale terminal status must not short-circuit the grace window into a modal flash.
directConnectStatus.value = 'failed';
attemptMock.mockImplementation(async () => {
directConnectStatus.value = 'connecting';
settingsStore.browserConnected = true;
});
const result = useBrowserUseConnection().ensureConnected('input_menu');
await vi.advanceTimersByTimeAsync(0);
await expect(result).resolves.toBe(true);
expect(uiStore.openModal).not.toHaveBeenCalled();
});
it('opens the modal at once when the extension cannot help', async () => {
attemptMock.mockImplementation(async () => {
directConnectStatus.value = 'unsupported';
});
const result = useBrowserUseConnection().ensureConnected('input_menu');
await vi.advanceTimersByTimeAsync(0);
// No point sitting out the grace window for a connect that already gave up.
expect(uiStore.openModal).toHaveBeenCalledWith(MODAL_KEY);
uiStore.closeModal(MODAL_KEY);
await vi.advanceTimersByTimeAsync(0);
await expect(result).resolves.toBe(false);
});
it('still opens the modal when no connect URL is available', async () => {
settingsStore.fetchBrowserConnectUrl.mockResolvedValue(null);
const result = useBrowserUseConnection().ensureConnected('input_menu');
await vi.advanceTimersByTimeAsync(0);
expect(attemptMock).not.toHaveBeenCalled();
expect(uiStore.openModal).toHaveBeenCalledWith(MODAL_KEY);
uiStore.closeModal(MODAL_KEY);
await vi.advanceTimersByTimeAsync(0);
await expect(result).resolves.toBe(false);
});
});
@@ -0,0 +1,127 @@
import { effectScope, watch } from 'vue';
import { until } from '@vueuse/core';
import { useToast } from '@n8n/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { listenForModalChanges, useUIStore } from '@/app/stores/ui.store';
import { INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY } from '../constants';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import type { BrowserUseModalSource } from '../instanceAiBrowserUse.telemetry';
import { useInstanceAiBrowserUseTelemetry } from '../instanceAiBrowserUse.telemetry';
import { beginConnectFlow, useExtensionDirectConnect } from './useExtensionDirectConnect';
/** Safety net only — the extension answers in milliseconds, or not at all. */
const EXTENSION_REPLY_TIMEOUT_MS = 5_000;
// Module-scoped: a second caller must join the running flow rather than start its own,
// because minting a link rotates the relay token and would strand the first one.
let inFlight: Promise<boolean> | null = null;
/**
* The one way to get Browser Use connected. Whether the setup modal is needed, and whether
* the extension can connect on its own, is decided here — no call site has to.
*/
export function useBrowserUseConnection() {
const i18n = useI18n();
const toast = useToast();
const uiStore = useUIStore();
const settingsStore = useInstanceAiSettingsStore();
const telemetry = useInstanceAiBrowserUseTelemetry();
const { status, isAttempting, attempt } = useExtensionDirectConnect();
/** Resolves true once the browser is attached, false if the user backed out. */
async function ensureConnected(source: BrowserUseModalSource): Promise<boolean> {
if (inFlight === null) {
const endFlow = beginConnectFlow();
inFlight = run(source).finally(() => {
inFlight = null;
endFlow();
});
}
return await inFlight;
}
async function run(source: BrowserUseModalSource): Promise<boolean> {
if (settingsStore.browserConnected) return true;
if (!isAttempting.value) {
const connectUrl = await settingsStore.fetchBrowserConnectUrl();
if (connectUrl) {
telemetry.trackDirectConnectRequested();
void attempt(connectUrl);
}
}
if (isAttempting.value) {
// The extension says which kind of connect this is, so the modal decision is its
// answer rather than a guess at how long a relay round trip should take.
await until(() => status.value !== 'idle').toBe(true, {
timeout: EXTENSION_REPLY_TIMEOUT_MS,
throwOnTimeout: false,
});
// An already-allowed host attaches with no prompt — a modal would only flash.
if (status.value === 'connecting' && (await waitForSilentConnect())) {
return announceConnected();
}
}
// The attempt stays in flight; the modal shares its state rather than starting its own.
telemetry.trackModalOpened(source);
uiStore.openModal(INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY);
if (!(await waitForConnectedOrDismissed())) return false;
uiStore.closeModal(INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY);
return announceConnected();
}
/** Not the modal's job: a remembered instance never opens it, so its toast would go unseen. */
function announceConnected(): true {
toast.showMessage({
type: 'success',
title: i18n.baseText('instanceAi.browserUse.connected'),
message: i18n.baseText('instanceAi.browserUse.connected.toastMessage'),
});
return true;
}
/**
* Bounded by the attempt landing on `failed`. Not bounded when the extension reports
* success but the backend push never arrives — see the follow-up on adding a timeout.
*/
async function waitForSilentConnect(): Promise<boolean> {
await until(() => settingsStore.browserConnected || status.value === 'failed').toBe(true, {
throwOnTimeout: false,
});
return settingsStore.browserConnected;
}
async function waitForConnectedOrDismissed(): Promise<boolean> {
// Level-triggered: the browser may have attached during the setup work above, and a
// plain `watch` would never fire for a value that is already true.
if (settingsStore.browserConnected) return true;
// The flow outlives the surface that started it, so these can't rely on it staying mounted.
const listeners = effectScope(true);
return await new Promise<boolean>((resolve) => {
listeners.run(() => {
const settle = (connected: boolean) => {
listeners.stop();
resolve(connected);
};
watch(
() => settingsStore.browserConnected,
(connected) => connected && settle(true),
);
listenForModalChanges({
store: uiStore,
onModalClosed: (name) => {
if (name === INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY) {
settle(settingsStore.browserConnected);
}
},
});
});
});
}
return { ensureConnected };
}
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
useExtensionDirectConnect,
resetExtensionDirectConnect,
DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS,
} from './useExtensionDirectConnect';
@@ -48,6 +49,7 @@ async function settle(): Promise<void> {
}
beforeEach(() => {
resetExtensionDirectConnect();
vi.useFakeTimers();
(globalThis as { chrome?: unknown }).chrome = chromeMock;
mockExtensionResponses({ connect: { accepted: true }, connectResult: HOLD_RESPONSE });
@@ -108,6 +110,43 @@ describe('useExtensionDirectConnect', () => {
);
});
it('clears the previous flow status before waiting on the extension', async () => {
mockExtensionResponses({ connect: HOLD_RESPONSE });
const { status, attempt } = useExtensionDirectConnect();
status.value = 'failed';
void attempt(CONNECT_URL);
// A caller watching this state must not read the outcome of a connect that ended.
expect(status.value).toBe('idle');
});
it('shares one live flow across independent callers', async () => {
const first = useExtensionDirectConnect();
const second = useExtensionDirectConnect();
void first.attempt(CONNECT_URL);
await vi.advanceTimersByTimeAsync(0);
// The modal mounts mid-flight and must render the running attempt, not start a second.
expect(second.status.value).toBe('waiting');
expect(second.isAttempting.value).toBe(true);
});
it('reports connecting, not waiting, when no confirmation was shown', async () => {
mockExtensionResponses({
connect: { accepted: true, confirmationRequired: false },
connectResult: HOLD_RESPONSE,
});
const { status, attempt } = useExtensionDirectConnect();
void attempt(CONNECT_URL);
await vi.advanceTimersByTimeAsync(0);
// A remembered host attaches silently — there is no popup to point the user at.
expect(status.value).toBe('connecting');
});
it('fails as soon as the extension reports an unsuccessful connect', async () => {
const { status, attempt } = useExtensionDirectConnect();
const pending = attempt(CONNECT_URL);
@@ -119,7 +158,7 @@ describe('useExtensionDirectConnect', () => {
expect(status.value).toBe('failed');
});
it('stays waiting when the extension reports a successful connect', async () => {
it('settles on a terminal state when the extension reports a successful connect', async () => {
const { status, attempt } = useExtensionDirectConnect();
const pending = attempt(CONNECT_URL);
await settle();
@@ -127,7 +166,9 @@ describe('useExtensionDirectConnect', () => {
heldCallbacks[0]({ connected: true });
await pending;
expect(status.value).toBe('waiting');
// A finished flow left on an in-progress status makes every later reader — the
// connections row especially — believe a connect is still running.
expect(status.value).toBe('connected');
});
it('fails when no connect result arrives in time', async () => {
@@ -1,8 +1,18 @@
import { ref, type Ref } from 'vue';
import { readonly, ref, type Ref } from 'vue';
export const DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS = 15_000;
export type DirectConnectStatus = 'idle' | 'unsupported' | 'waiting' | 'failed';
/**
* `waiting` needs the user to act on a popup; `connecting` is attaching with no prompt.
* `connected` and `failed` are terminal — the flow is over and nothing more will change it.
*/
export type DirectConnectStatus =
| 'idle'
| 'unsupported'
| 'waiting'
| 'connecting'
| 'connected'
| 'failed';
interface ExtensionRuntime {
sendMessage: (
@@ -44,6 +54,34 @@ async function timeout(ms: number): Promise<undefined> {
return await new Promise((resolve) => setTimeout(() => resolve(undefined), ms));
}
// Module-scoped because a flow outlives the component that starts it: the credential card
// kicks off an attempt and the setup modal mounts mid-flight. Sharing lets that modal show
// the live status instead of firing a second request the extension would throttle.
const status: Ref<DirectConnectStatus> = ref('idle');
const isAttempting = ref(false);
// `isAttempting` only spans the extension round trip. An orchestrated flow outlives it —
// it runs until the browser attaches — so anything asking "is a connect already under way?"
// must read this instead, or it will start a second one in the gap.
const isFlowActive = ref(false);
/** Marks an orchestrated flow as running; call the returned function when it settles. */
export function beginConnectFlow(): () => void {
isFlowActive.value = true;
return () => {
isFlowActive.value = false;
};
}
/**
* Clears the shared flow state. A finished flow leaves a terminal status behind, which the
* next view to mount would otherwise inherit as a spinner for a connect that already ended.
*/
export function resetExtensionDirectConnect(): void {
status.value = 'idle';
isAttempting.value = false;
isFlowActive.value = false;
}
/**
* Direct Browser Use connect flow: asks the installed extension (via
* `externally_connectable` messaging) to show its connect confirmation in an
@@ -51,16 +89,15 @@ async function timeout(ms: number): Promise<undefined> {
* the extension did not open the popup — callers show the link-based flow.
*/
export function useExtensionDirectConnect() {
const status: Ref<DirectConnectStatus> = ref('idle');
let isAttempting = false;
async function attempt(connectUrl: string): Promise<void> {
if (isAttempting) return;
isAttempting = true;
if (isAttempting.value) return;
isAttempting.value = true;
// Before any await, so a caller watching this doesn't read the last flow's outcome.
status.value = 'idle';
try {
await runAttempt(connectUrl);
} finally {
isAttempting = false;
isAttempting.value = false;
}
}
@@ -78,18 +115,21 @@ export function useExtensionDirectConnect() {
return;
}
let needsConfirmation = true;
try {
const response = await sendToExtension(runtime, extensionId, { type: 'connect', relayUrl });
if (!isRecord(response) || response.accepted !== true) {
status.value = 'unsupported';
return;
}
// Older extensions omit the flag; assume the popup was shown.
needsConfirmation = response.confirmationRequired !== false;
} catch {
status.value = 'unsupported';
return;
}
status.value = 'waiting';
status.value = needsConfirmation ? 'waiting' : 'connecting';
let connected = false;
try {
@@ -99,10 +139,15 @@ export function useExtensionDirectConnect() {
]);
connected = isRecord(result) && result.connected === true;
} catch {}
if (!connected) {
status.value = 'failed';
}
// Land on a terminal state either way. Leaving a finished flow on an in-progress
// status makes every later reader believe a connect is still running.
status.value = connected ? 'connected' : 'failed';
}
return { status, attempt };
return {
status,
isAttempting: readonly(isAttempting),
isFlowActive: readonly(isFlowActive),
attempt,
};
}
@@ -7,15 +7,14 @@ import { useInstanceAiBrowserUseExperiment } from '@/experiments/instanceAiBrows
import { useInstanceAiComputerUseExperiment } from '@/experiments/instanceAiComputerUse';
import type { ToolConnectionStatus, ToolIconSource } from '@/features/shared/toolsConnection/types';
import {
INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY,
INSTANCE_AI_COMPUTER_USE_SETUP_MODAL_KEY,
INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY,
} from '../constants';
import { useInstanceAiMcpStore } from '../instanceAiMcp.store';
import { useInstanceAiMcpTelemetry } from '../instanceAiMcp.telemetry';
import { useInstanceAiBrowserUseTelemetry } from '../instanceAiBrowserUse.telemetry';
import { useInstanceAiComputerUseTelemetry } from '../instanceAiComputerUse.telemetry';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { useBrowserUseConnection } from './useBrowserUseConnection';
import { iconForTool } from '../toolIcons';
type InputMenuItemData = {
@@ -32,7 +31,7 @@ export function useInstanceAiInputMenuItems(attachFiles: () => void) {
const settingsStore = useInstanceAiSettingsStore();
const mcpStore = useInstanceAiMcpStore();
const mcpTelemetry = useInstanceAiMcpTelemetry();
const browserUseTelemetry = useInstanceAiBrowserUseTelemetry();
const { ensureConnected: ensureBrowserConnected } = useBrowserUseConnection();
const computerUseTelemetry = useInstanceAiComputerUseTelemetry();
const { isFeatureEnabled: isMcpFeatureEnabled } = useInstanceAiMcpConnectionsExperiment();
const { isFeatureEnabled: isBrowserUseFeatureEnabled } = useInstanceAiBrowserUseExperiment();
@@ -266,9 +265,10 @@ export function useInstanceAiInputMenuItems(attachFiles: () => void) {
settingsStore.browserUseConnectionStatus !== 'none'
? i18n.baseText('instanceAi.inputMenu.browser.connectedTitle')
: undefined,
connect: () => {
browserUseTelemetry.trackModalOpened('input_menu');
uiStore.openModal(INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY);
// An instance the user allowed reconnects with no modal at all, so the flow
// decides whether one is needed — and reports the open when it is.
connect: async () => {
await ensureBrowserConnected('input_menu');
},
disconnect: settingsStore.disconnectBrowserUse,
}),