mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat: Remember allowed Browser Use hosts (no-changelog) (#36744)
This commit is contained in:
committed by
GitHub
parent
d008bb9bd5
commit
32bc2e0ea1
@@ -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.",
|
||||
|
||||
+15
-5
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+4
-37
@@ -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>
|
||||
|
||||
|
||||
+47
-10
@@ -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
-21
@@ -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.
|
||||
|
||||
+76
-1
@@ -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);
|
||||
|
||||
+3
-40
@@ -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 () => {
|
||||
|
||||
+220
@@ -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);
|
||||
});
|
||||
});
|
||||
+127
@@ -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 };
|
||||
}
|
||||
+43
-2
@@ -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 () => {
|
||||
|
||||
+58
-13
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-6
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user