Merge remote-tracking branch 'origin/browser-use' into electron-app

# Conflicts:
#	src/i18n/locales/en/settings.json
This commit is contained in:
Simos Mikelatos
2026-06-17 20:17:38 +00:00
19 changed files with 727 additions and 913 deletions

View File

@@ -53,7 +53,7 @@ async function callBrowserUseApi(toolName: string, input: Record<string, unknown
});
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
if (!response.ok || data.success === false) {
throw new Error(data.error || `Browser Use API request failed (${response.status})`);
throw new Error(data.error || `Browser API request failed (${response.status})`);
}
return data.data;
}
@@ -61,7 +61,7 @@ async function callBrowserUseApi(toolName: string, input: Record<string, unknown
const sessionIdSchema = {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Browser Use session id.' },
sessionId: { type: 'string', description: 'Browser session id.' },
},
required: ['sessionId'],
};
@@ -69,7 +69,7 @@ const sessionIdSchema = {
const tools: ToolDefinition[] = [
{
name: 'browser_create_session',
description: 'Create a temporary Browser Use session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
inputSchema: {
type: 'object',
properties: {
@@ -79,22 +79,22 @@ const tools: ToolDefinition[] = [
},
{
name: 'browser_list_sessions',
description: 'List Browser Use sessions currently available to agents.',
description: 'List Browser sessions currently available to agents.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browser_snapshot',
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser Use session.',
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_take_screenshot',
description: 'Capture the latest screenshot for a Browser Use session.',
description: 'Capture the latest screenshot for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_navigate',
description: 'Navigate a Browser Use session to an HTTP or HTTPS URL.',
description: 'Navigate a Browser session to an HTTP or HTTPS URL.',
inputSchema: {
type: 'object',
properties: {
@@ -196,7 +196,7 @@ const tools: ToolDefinition[] = [
},
{
name: 'browser_tabs',
description: 'List, open, select, or close tabs in a Browser Use session.',
description: 'List, open, select, or close tabs in a Browser session.',
inputSchema: {
type: 'object',
properties: {
@@ -210,7 +210,7 @@ const tools: ToolDefinition[] = [
},
{
name: 'browser_close_session',
description: 'Stop a Browser Use session controlled by agents.',
description: 'Stop a Browser session controlled by agents.',
inputSchema: sessionIdSchema,
},
];
@@ -302,7 +302,7 @@ async function handleMessage(message: JsonRpcRequest) {
return {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'cloudcli-browser-use', version: '1.0.0' },
serverInfo: { name: 'cloudcli-browser', version: '1.0.0' },
};
}
@@ -327,8 +327,9 @@ async function handleMessage(message: JsonRpcRequest) {
}
function writeMessage(message: Record<string, unknown>) {
const payload = JSON.stringify(message);
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
// MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
// no embedded newlines). This is NOT the LSP Content-Length framing.
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendResult(id: string | number | null | undefined, result: unknown) {
@@ -352,33 +353,18 @@ function sendError(id: string | number | null | undefined, error: unknown) {
});
}
let buffer = Buffer.alloc(0);
let buffer = '';
process.stdin.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
const headerEnd = buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const header = buffer.slice(0, headerEnd).toString('utf8');
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
if (!lengthMatch) {
buffer = buffer.slice(headerEnd + 4);
buffer += chunk.toString('utf8');
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!rawMessage) {
continue;
}
const length = Number.parseInt(lengthMatch[1], 10);
const messageStart = headerEnd + 4;
const messageEnd = messageStart + length;
if (buffer.length < messageEnd) {
return;
}
const rawMessage = buffer.slice(messageStart, messageEnd).toString('utf8');
buffer = buffer.slice(messageEnd);
void (async () => {
let request: JsonRpcRequest;
try {

View File

@@ -8,7 +8,7 @@
* (no args) - Start the server (default)
* start - Start the server
* sandbox - Manage Docker sandbox environments
* browser-use-mcp - Run Browser Use MCP stdio server
* browser-use-mcp - Run Browser MCP stdio server
* status - Show configuration and data locations
* help - Show help information
* version - Show version information
@@ -157,7 +157,7 @@ Usage:
Commands:
start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments
browser-use-mcp Run the Browser Use MCP stdio server
browser-use-mcp Run the Browser MCP stdio server
status Show configuration and data locations
update Update to the latest version
help Show this help information

View File

@@ -199,10 +199,10 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Browser Use MCP bridge API (local token protected)
// Browser MCP bridge API (local token protected)
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
// Browser Use API Routes (protected)
// Browser API Routes (protected)
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
// Computer Use MCP bridge API (local token protected)
@@ -1763,7 +1763,7 @@ async function startServer() {
try {
await browserUseService.stopAllSessions();
} catch (err) {
console.error('[Browser Use] Error stopping sessions during shutdown:', err?.message || err);
console.error('[Browser] Error stopping sessions during shutdown:', err?.message || err);
}
try {
await computerUseService.stopAllSessions();

View File

@@ -16,7 +16,7 @@ router.use((req, res, next) => {
const expected = browserUseService.getMcpToken();
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
if (!token || token !== expected) {
res.status(401).json({ success: false, error: 'Invalid Browser Use MCP token.' });
res.status(401).json({ success: false, error: 'Invalid Browser MCP token.' });
return;
}
next();
@@ -104,7 +104,7 @@ router.post('/tools/:toolName', async (req, res) => {
result = await browserUseService.agentStopSession(sessionId);
break;
default:
res.status(404).json({ success: false, error: `Unknown Browser Use MCP tool "${toolName}".` });
res.status(404).json({ success: false, error: `Unknown Browser MCP tool "${toolName}".` });
return;
}
@@ -112,7 +112,7 @@ router.post('/tools/:toolName', async (req, res) => {
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Browser Use MCP tool failed.',
error: error instanceof Error ? error.message : 'Browser MCP tool failed.',
});
}
});

View File

@@ -4,20 +4,6 @@ import { browserUseService } from '@/modules/browser-use/browser-use.service.js'
const router = express.Router();
type AuthenticatedRequest = express.Request & {
user?: {
id?: string | number;
};
};
function requireUser(req: AuthenticatedRequest): { id: string | number } {
const userId = req.user?.id;
if (userId === undefined || userId === null) {
throw new Error('Authenticated user is required.');
}
return { id: userId };
}
function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
@@ -28,7 +14,7 @@ router.get('/status', async (_req, res) => {
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser Use status.',
error: error instanceof Error ? error.message : 'Failed to load Browser status.',
});
}
});
@@ -39,7 +25,7 @@ router.get('/settings', async (_req, res) => {
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser Use settings.',
error: error instanceof Error ? error.message : 'Failed to load Browser settings.',
});
}
});
@@ -51,19 +37,7 @@ router.put('/settings', async (req, res) => {
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to save Browser Use settings.',
});
}
});
router.post('/agent-tools/register', async (_req, res) => {
try {
const result = await browserUseService.registerAgentMcp();
res.status(201).json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to register Browser Use MCP.',
error: error instanceof Error ? error.message : 'Failed to save Browser settings.',
});
}
});
@@ -79,14 +53,14 @@ router.post('/runtime/install', async (_req, res) => {
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to install Browser Use runtime.',
error: error instanceof Error ? error.message : 'Failed to install Browser runtime.',
});
}
});
router.get('/sessions', async (req: AuthenticatedRequest, res) => {
router.get('/sessions', async (_req, res) => {
try {
res.json({ success: true, data: { sessions: await browserUseService.listSessions(requireUser(req)) } });
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
} catch (error) {
res.status(401).json({
success: false,
@@ -95,90 +69,9 @@ router.get('/sessions', async (req: AuthenticatedRequest, res) => {
}
});
router.post('/sessions', async (req: AuthenticatedRequest, res) => {
router.post('/sessions/:sessionId/stop', async (req, res) => {
try {
const session = await browserUseService.createSession(requireUser(req));
res.status(session.status === 'unavailable' ? 202 : 201).json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to create browser session.',
});
}
});
router.post('/sessions/:sessionId/navigate', async (req: AuthenticatedRequest, res) => {
try {
const session = await browserUseService.navigate(requireUser(req), readParam(req.params.sessionId), String(req.body?.url || ''));
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to navigate browser session.',
});
}
});
router.post('/sessions/:sessionId/click', async (req: AuthenticatedRequest, res) => {
try {
const x = Number(req.body?.x);
const y = Number(req.body?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error('Click requires numeric x and y coordinates.');
}
const session = await browserUseService.userClick(requireUser(req), readParam(req.params.sessionId), { x, y });
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to click browser session.',
});
}
});
router.post('/sessions/:sessionId/press-key', async (req: AuthenticatedRequest, res) => {
try {
const key = String(req.body?.key || '').trim();
if (!key) {
throw new Error('A key is required.');
}
const session = await browserUseService.userPressKey(requireUser(req), readParam(req.params.sessionId), key);
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to send browser key input.',
});
}
});
router.post('/sessions/:sessionId/agent-access/grant', async (req: AuthenticatedRequest, res) => {
try {
const session = await browserUseService.grantAgentAccess(requireUser(req), readParam(req.params.sessionId));
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to grant agent access.',
});
}
});
router.post('/sessions/:sessionId/agent-access/revoke', async (req: AuthenticatedRequest, res) => {
try {
const session = await browserUseService.revokeAgentAccess(requireUser(req), readParam(req.params.sessionId));
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to revoke agent access.',
});
}
});
router.post('/sessions/:sessionId/stop', async (req: AuthenticatedRequest, res) => {
try {
const result = await browserUseService.stopSession(requireUser(req), readParam(req.params.sessionId));
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
@@ -188,9 +81,9 @@ router.post('/sessions/:sessionId/stop', async (req: AuthenticatedRequest, res)
}
});
router.delete('/sessions/:sessionId', async (req: AuthenticatedRequest, res) => {
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const result = await browserUseService.deleteSession(requireUser(req), readParam(req.params.sessionId));
const result = await browserUseService.deleteSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({

View File

@@ -1,14 +1,12 @@
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import dns from 'node:dns/promises';
import fs from 'node:fs';
import os from 'node:os';
import net from 'node:net';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js';
const require = createRequire(import.meta.url);
@@ -16,7 +14,6 @@ const __dirname = getModuleDir(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const ALLOW_PRIVATE_NETWORKS = process.env.CLOUDCLI_BROWSER_USE_ALLOW_PRIVATE_NETWORKS === '1';
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
@@ -26,7 +23,7 @@ type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'user' | 'agent';
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
@@ -36,7 +33,6 @@ type BrowserUseSession = {
updatedAt: string;
lastAction: string | null;
message: string | null;
agentAccessEnabled: boolean;
profileName: string | null;
viewport: {
width: number;
@@ -45,7 +41,7 @@ type BrowserUseSession = {
cursor: {
x: number;
y: number;
actor: 'agent' | 'user';
actor: 'agent';
} | null;
};
@@ -57,13 +53,8 @@ type RuntimeHandle = {
page?: any;
};
type BrowserUseOwner = {
id: string | number;
};
type BrowserUseSettings = {
enabled: boolean;
agentToolsEnabled: boolean;
};
type RuntimeReadiness = {
@@ -75,19 +66,22 @@ type RuntimeReadiness = {
installMessage: string | null;
};
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null;
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: false,
agentToolsEnabled: false,
};
const AGENT_OWNER_ID = 'agent';
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
const MCP_SERVER_NAME = 'cloudcli-browser-use';
const MCP_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'opencode'];
const MCP_SERVER_NAME = 'cloudcli-browser';
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
function getRuntime(): BrowserUseRuntime {
return IS_PLATFORM ? 'cloud' : 'local';
@@ -103,10 +97,9 @@ function readSettings(): BrowserUseSettings {
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled === true,
agentToolsEnabled: parsed.agentToolsEnabled === true,
};
} catch (error: any) {
console.warn('[Browser Use] Failed to read settings:', error?.message || error);
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_SETTINGS;
}
}
@@ -114,7 +107,6 @@ function readSettings(): BrowserUseSettings {
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
agentToolsEnabled: settings.agentToolsEnabled === true,
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
@@ -133,7 +125,7 @@ function getOrCreateMcpToken(): string {
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) {
return 'Browser Use is disabled in settings.';
return 'Browser is disabled in settings.';
}
if (!readiness.playwrightInstalled) {
@@ -144,7 +136,7 @@ function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadine
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
}
return readiness.installMessage || 'Browser Use runtime is not ready.';
return readiness.installMessage || 'Browser runtime is not ready.';
}
function getPlaywright(): any | null {
@@ -176,6 +168,14 @@ function getMcpApiUrl(): string {
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
}
async function removeMcpServerFromAllProviders(name: string) {
const results = await providerMcpService.removeMcpServerFromAllProviders({
name,
scope: 'user',
});
return results.map((result) => ({ ...result, name }));
}
function normalizeProfileName(profileName?: string | null): string | null {
const normalized = String(profileName || '').trim();
if (!normalized) {
@@ -194,15 +194,13 @@ function getProfilePath(profileName: string): string {
return path.join(PROFILE_ROOT, safeName);
}
function getRuntimeReadiness(): RuntimeReadiness {
function probeRuntime(): RuntimeProbe {
const playwright = getPlaywright();
const readiness: RuntimeReadiness = {
const readiness: RuntimeProbe = {
playwright,
playwrightInstalled: Boolean(playwright),
chromiumInstalled: false,
chromiumExecutablePath: null,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
};
if (!playwright) {
@@ -220,6 +218,26 @@ function getRuntimeReadiness(): RuntimeReadiness {
return readiness;
}
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
const now = Date.now();
const cachedProbe = runtimeProbeCache;
const canUseCache = !options.force
&& !installPromise
&& cachedProbe
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
const probe = canUseCache ? cachedProbe.value : probeRuntime();
if (!canUseCache && !installPromise) {
runtimeProbeCache = { value: probe, updatedAt: now };
}
return {
...probe,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
};
}
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
10,
@@ -244,7 +262,6 @@ function runCommand(command: string, args: string[]): Promise<void> {
fn();
};
// Guard against a stuck npm/playwright process hanging the install request forever.
const timer = setTimeout(() => {
child.kill('SIGKILL');
finish(() => reject(new Error(
@@ -272,7 +289,7 @@ function formatInstallError(error: unknown): string {
if (message.includes('sudo') && message.includes('password')) {
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
}
return message || 'Failed to install Browser Use runtime.';
return message || 'Failed to install Browser runtime.';
}
async function installRuntime(): Promise<{ success: boolean; message: string }> {
@@ -281,6 +298,7 @@ async function installRuntime(): Promise<{ success: boolean; message: string }>
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
runtimeProbeCache = null;
installPromise = (async () => {
try {
lastInstallMessage = 'Installing Playwright package...';
@@ -294,7 +312,7 @@ async function installRuntime(): Promise<{ success: boolean; message: string }>
lastInstallMessage = 'Installing Chromium runtime...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
lastInstallMessage = 'Browser Use runtime installed.';
lastInstallMessage = 'Browser runtime installed.';
return { success: true, message: lastInstallMessage };
} catch (error) {
lastInstallMessage = formatInstallError(error);
@@ -306,82 +324,11 @@ async function installRuntime(): Promise<{ success: boolean; message: string }>
return await installPromise;
} finally {
installPromise = null;
runtimeProbeCache = null;
}
}
function getOwnerId(owner: BrowserUseOwner): string {
if (owner.id === undefined || owner.id === null || String(owner.id).trim() === '') {
throw new Error('Authenticated user is required.');
}
return String(owner.id);
}
function isPrivateIpv4(address: string): boolean {
const parts = address.split('.').map((part) => Number.parseInt(part, 10));
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
return true;
}
const [first, second] = parts;
return first === 0
|| first === 10
|| first === 127
|| (first === 169 && second === 254)
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 168)
|| first >= 224;
}
function isPrivateIpv6(address: string): boolean {
const normalized = address.toLowerCase();
return normalized === '::1'
|| normalized === '::'
|| normalized.startsWith('fc')
|| normalized.startsWith('fd')
|| normalized.startsWith('fe80:')
|| normalized.startsWith('::ffff:127.')
|| normalized.startsWith('::ffff:10.')
|| normalized.startsWith('::ffff:192.168.')
|| /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized)
|| /^::ffff:169\.254\./.test(normalized);
}
export function isBlockedBrowserUseAddress(address: string): boolean {
const version = net.isIP(address);
if (version === 4) {
return isPrivateIpv4(address);
}
if (version === 6) {
return isPrivateIpv6(address);
}
return true;
}
async function assertPublicHttpTarget(parsedUrl: URL): Promise<void> {
if (ALLOW_PRIVATE_NETWORKS) {
return;
}
const hostname = parsedUrl.hostname;
if (!hostname) {
throw new Error('URL hostname is required.');
}
if (net.isIP(hostname)) {
if (isBlockedBrowserUseAddress(hostname)) {
throw new Error('Browser Use cannot navigate to private or local network addresses.');
}
return;
}
const addresses = await dns.lookup(hostname, { all: true, verbatim: true });
if (addresses.length === 0 || addresses.some((entry) => isBlockedBrowserUseAddress(entry.address))) {
throw new Error('Browser Use cannot navigate to private or local network addresses.');
}
}
async function normalizeUrl(rawUrl: string): Promise<string> {
function normalizeUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (!trimmed) {
throw new Error('URL is required.');
@@ -395,33 +342,9 @@ async function normalizeUrl(rawUrl: string): Promise<string> {
throw new Error('Only http and https URLs are supported.');
}
await assertPublicHttpTarget(parsed);
return parsed.toString();
}
async function assertAllowedBrowserRequest(rawUrl: string): Promise<void> {
const parsed = new URL(rawUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return;
}
await assertPublicHttpTarget(parsed);
}
async function attachRequestGuard(context: any): Promise<void> {
// Attach at the context level so the guard also covers popups, window.open targets,
// and any replacement pages created during the session lifecycle.
await context.route('**/*', async (route: any) => {
try {
await assertAllowedBrowserRequest(route.request().url());
await route.continue();
} catch {
await route.abort('blockedbyclient');
}
});
}
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
const { ownerId: _ownerId, ...publicFields } = session;
return publicFields;
@@ -431,10 +354,6 @@ function ownerSessions(ownerId: string): BrowserUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
}
function canAccessSession(ownerId: string, session: BrowserUseSession): boolean {
return session.ownerId === ownerId || session.ownerId === AGENT_OWNER_ID || session.agentAccessEnabled;
}
async function closeHandle(sessionId: string): Promise<void> {
const handle = handles.get(sessionId);
handles.delete(sessionId);
@@ -504,21 +423,15 @@ export const browserUseService = {
async updateSettings(settings: Partial<BrowserUseSettings>) {
const current = readSettings();
const nextSettings = {
...current,
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
agentToolsEnabled: typeof settings.agentToolsEnabled === 'boolean'
? settings.agentToolsEnabled
: current.agentToolsEnabled,
};
if (!nextSettings.enabled) {
nextSettings.agentToolsEnabled = false;
}
const next = writeSettings(nextSettings);
if (next.agentToolsEnabled) {
if (next.enabled) {
await this.registerAgentMcp();
} else if (current.agentToolsEnabled) {
} else if (current.enabled) {
await this.unregisterAgentMcp();
await this.stopAllSessions();
}
return next;
},
@@ -536,16 +449,15 @@ export const browserUseService = {
chromiumInstalled: readiness.chromiumInstalled,
installInProgress: readiness.installInProgress,
sessionCount: sessions.size,
agentToolsEnabled: settings.agentToolsEnabled,
mcpRecommended: !settings.agentToolsEnabled,
message: available
? 'Browser Use runtime is available.'
? 'Browser runtime is available.'
: getSetupMessage(settings, readiness),
};
},
async registerAgentMcp() {
const { command, args } = getMcpCommand();
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
const results = await providerMcpService.addMcpServerToAllProviders({
name: MCP_SERVER_NAME,
scope: 'user',
@@ -565,21 +477,9 @@ export const browserUseService = {
},
async unregisterAgentMcp() {
const results = await Promise.all(MCP_PROVIDERS.map(async (provider) => {
try {
const result = await providerMcpService.removeProviderMcpServer(provider, {
name: MCP_SERVER_NAME,
scope: 'user',
});
return { provider, removed: result.removed };
} catch (error) {
return {
provider,
removed: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}));
const results = (await Promise.all(
[MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
)).flat();
return { name: MCP_SERVER_NAME, results };
},
@@ -591,25 +491,27 @@ export const browserUseService = {
};
},
async listSessions(owner: BrowserUseOwner) {
const ownerId = getOwnerId(owner);
async listSessions() {
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => canAccessSession(ownerId, session))
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async createSession(owner: BrowserUseOwner, options?: { createdBy?: 'user' | 'agent'; profileName?: string | null; agentAccessEnabled?: boolean }) {
const ownerId = getOwnerId(owner);
async createAgentSession(options?: { profileName?: string | null }) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
await expireStaleSessions();
const createdBy = options?.createdBy ?? 'user';
const profileName = normalizeProfileName(options?.profileName);
const now = new Date().toISOString();
const session: BrowserUseSession = {
id: randomUUID(),
ownerId,
createdBy,
ownerId: AGENT_OWNER_ID,
createdBy: 'agent',
runtime: getRuntime(),
status: 'unavailable',
url: null,
@@ -619,18 +521,16 @@ export const browserUseService = {
updatedAt: now,
lastAction: 'create',
message: null,
agentAccessEnabled: options?.agentAccessEnabled ?? createdBy === 'agent',
profileName,
viewport: { width: 1440, height: 900 },
cursor: null,
};
const activeOwnerSessions = ownerSessions(ownerId).filter((item) => item.status === 'ready');
const activeOwnerSessions = ownerSessions(AGENT_OWNER_ID).filter((item) => item.status === 'ready');
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
throw new Error(`Browser Use is limited to ${MAX_SESSIONS_PER_OWNER} active sessions per user.`);
throw new Error(`Browser is limited to ${MAX_SESSIONS_PER_OWNER} active agent sessions.`);
}
const settings = readSettings();
const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
session.message = getSetupMessage(settings, readiness);
@@ -662,7 +562,6 @@ export const browserUseService = {
context = await browser.newContext(contextOptions);
page = await context.newPage();
}
await attachRequestGuard(context);
session.status = 'ready';
session.message = 'Browser session is ready.';
sessions.set(session.id, session);
@@ -671,70 +570,35 @@ export const browserUseService = {
return publicSession(session);
},
async grantAgentAccess(owner: BrowserUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || (session.ownerId !== ownerId && session.ownerId !== AGENT_OWNER_ID)) {
throw new Error('Browser session not found.');
}
session.agentAccessEnabled = true;
session.updatedAt = new Date().toISOString();
session.lastAction = 'agent_access:grant';
return publicSession(session);
},
async revokeAgentAccess(owner: BrowserUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || (session.ownerId !== ownerId && session.ownerId !== AGENT_OWNER_ID)) {
throw new Error('Browser session not found.');
}
session.agentAccessEnabled = false;
session.updatedAt = new Date().toISOString();
session.lastAction = 'agent_access:revoke';
return publicSession(session);
},
async listAgentSessions() {
const settings = readSettings();
if (!settings.enabled || !settings.agentToolsEnabled) {
if (!settings.enabled) {
return [];
}
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.agentAccessEnabled || session.ownerId === AGENT_OWNER_ID)
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async createAgentSession(options?: { profileName?: string | null }) {
const settings = readSettings();
if (!settings.enabled || !settings.agentToolsEnabled) {
throw new Error('Browser Use agent tools are disabled.');
}
return this.createSession(
{ id: AGENT_OWNER_ID },
{ createdBy: 'agent', profileName: options?.profileName, agentAccessEnabled: true },
);
},
async getAgentSession(sessionId: string) {
const settings = readSettings();
if (!settings.enabled || !settings.agentToolsEnabled) {
throw new Error('Browser Use agent tools are disabled.');
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
const session = sessions.get(sessionId);
if (!session || (!session.agentAccessEnabled && session.ownerId !== AGENT_OWNER_ID)) {
throw new Error('Browser session is not shared with agents.');
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
return session;
},
async navigate(owner: BrowserUseOwner, sessionId: string, rawUrl: string) {
const ownerId = getOwnerId(owner);
async agentNavigate(sessionId: string, rawUrl: string) {
await this.getAgentSession(sessionId);
await expireStaleSessions();
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
@@ -747,7 +611,7 @@ export const browserUseService = {
throw new Error('Browser runtime handle is not available.');
}
const url = await normalizeUrl(rawUrl);
const url = normalizeUrl(rawUrl);
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
session.lastAction = `navigate:${url}`;
session.cursor = null;
@@ -755,25 +619,6 @@ export const browserUseService = {
return publicSession(session);
},
async agentNavigate(sessionId: string, rawUrl: string) {
await this.getAgentSession(sessionId);
return this.navigate({ id: AGENT_OWNER_ID }, sessionId, rawUrl).catch(async (error) => {
const session = await this.getAgentSession(sessionId);
if (session.ownerId !== AGENT_OWNER_ID) {
const url = await normalizeUrl(rawUrl);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
session.lastAction = `navigate:${url}`;
await captureSession(session, handle.page);
return publicSession(session);
}
throw error;
});
},
async agentSnapshot(sessionId: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
@@ -911,7 +756,6 @@ export const browserUseService = {
if (action === 'new') {
const page = await handle.context.newPage();
handles.set(sessionId, { ...handle, page });
// Request guard is attached at the context level, so new pages are already covered.
if (input.url) {
await this.agentNavigate(sessionId, input.url);
}
@@ -942,10 +786,9 @@ export const browserUseService = {
};
},
async stopSession(owner: BrowserUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
async stopSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { stopped: false };
}
@@ -958,10 +801,9 @@ export const browserUseService = {
return { stopped: true, session: publicSession(session) };
},
async deleteSession(owner: BrowserUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
async deleteSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { deleted: false };
}
@@ -970,52 +812,9 @@ export const browserUseService = {
return { deleted: true, sessionId };
},
async userClick(owner: BrowserUseOwner, sessionId: string, input: { x: number; y: number }) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
throw new Error('Browser session not found.');
}
if (session.status !== 'ready') {
throw new Error(session.message || 'Browser session is not available.');
}
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.mouse.click(input.x, input.y);
session.lastAction = 'click';
session.cursor = { x: input.x, y: input.y, actor: 'user' };
await captureSession(session, handle.page);
return publicSession(session);
},
async userPressKey(owner: BrowserUseOwner, sessionId: string, key: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
throw new Error('Browser session not found.');
}
if (session.status !== 'ready') {
throw new Error(session.message || 'Browser session is not available.');
}
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.keyboard.press(key);
session.lastAction = `press_key:${key}`;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentStopSession(sessionId: string) {
await this.getAgentSession(sessionId);
return this.stopSession({ id: AGENT_OWNER_ID }, sessionId);
return this.stopSession(sessionId);
},
async stopAllSessions() {

View File

@@ -1,30 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { browserUseService, isBlockedBrowserUseAddress } from '@/modules/browser-use/browser-use.service.js';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
test('browser use blocks private and local network addresses by default', () => {
assert.equal(isBlockedBrowserUseAddress('127.0.0.1'), true);
assert.equal(isBlockedBrowserUseAddress('10.0.0.12'), true);
assert.equal(isBlockedBrowserUseAddress('172.16.4.8'), true);
assert.equal(isBlockedBrowserUseAddress('192.168.1.4'), true);
assert.equal(isBlockedBrowserUseAddress('169.254.169.254'), true);
assert.equal(isBlockedBrowserUseAddress('::1'), true);
assert.equal(isBlockedBrowserUseAddress('8.8.8.8'), false);
assert.equal(isBlockedBrowserUseAddress('2001:4860:4860::8888'), false);
});
test('browser use sessions are listed only for their owner', async () => {
const ownerA = { id: `owner-a-${Date.now()}-${Math.random()}` };
const ownerB = { id: `owner-b-${Date.now()}-${Math.random()}` };
const ownerASession = await browserUseService.createSession(ownerA);
await browserUseService.createSession(ownerB);
const ownerASessions = await browserUseService.listSessions(ownerA);
const ownerBSessions = await browserUseService.listSessions(ownerB);
assert.equal(ownerASessions.some((session) => session.id === ownerASession.id), true);
assert.equal(ownerBSessions.some((session) => session.id === ownerASession.id), false);
assert.equal(Object.hasOwn(ownerASession, 'ownerId'), false);
test('browser monitor list starts empty without agent sessions', async () => {
const sessions = await browserUseService.listSessions();
assert.deepEqual(sessions, []);
});

View File

@@ -1,5 +1,6 @@
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
export { providerSkillsService } from './services/skills.service.js';
export { providerMcpService } from './services/mcp.service.js';
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';

View File

@@ -80,4 +80,30 @@ export const providerMcpService = {
return results;
},
/**
* Removes one MCP server from every provider. Mirrors `addMcpServerToAllProviders`
* by iterating the live provider registry, so callers stay in sync with which
* providers exist instead of maintaining their own provider list.
*/
async removeMcpServerFromAllProviders(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<Array<{ provider: LLMProvider; removed: boolean; error?: string }>> {
const results: Array<{ provider: LLMProvider; removed: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders();
for (const provider of providers) {
try {
const result = await provider.mcp.removeServer(input);
results.push({ provider: provider.id, removed: result.removed });
} catch (error) {
results.push({
provider: provider.id,
removed: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return results;
},
};