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 }; const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
if (!response.ok || data.success === false) { 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; return data.data;
} }
@@ -61,7 +61,7 @@ async function callBrowserUseApi(toolName: string, input: Record<string, unknown
const sessionIdSchema = { const sessionIdSchema = {
type: 'object', type: 'object',
properties: { properties: {
sessionId: { type: 'string', description: 'Browser Use session id.' }, sessionId: { type: 'string', description: 'Browser session id.' },
}, },
required: ['sessionId'], required: ['sessionId'],
}; };
@@ -69,7 +69,7 @@ const sessionIdSchema = {
const tools: ToolDefinition[] = [ const tools: ToolDefinition[] = [
{ {
name: 'browser_create_session', 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -79,22 +79,22 @@ const tools: ToolDefinition[] = [
}, },
{ {
name: 'browser_list_sessions', 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: {} }, inputSchema: { type: 'object', properties: {} },
}, },
{ {
name: 'browser_snapshot', 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, inputSchema: sessionIdSchema,
}, },
{ {
name: 'browser_take_screenshot', 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, inputSchema: sessionIdSchema,
}, },
{ {
name: 'browser_navigate', 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -196,7 +196,7 @@ const tools: ToolDefinition[] = [
}, },
{ {
name: 'browser_tabs', 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -210,7 +210,7 @@ const tools: ToolDefinition[] = [
}, },
{ {
name: 'browser_close_session', name: 'browser_close_session',
description: 'Stop a Browser Use session controlled by agents.', description: 'Stop a Browser session controlled by agents.',
inputSchema: sessionIdSchema, inputSchema: sessionIdSchema,
}, },
]; ];
@@ -302,7 +302,7 @@ async function handleMessage(message: JsonRpcRequest) {
return { return {
protocolVersion: '2024-11-05', protocolVersion: '2024-11-05',
capabilities: { tools: {} }, 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>) { function writeMessage(message: Record<string, unknown>) {
const payload = JSON.stringify(message); // MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`); // 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) { 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) => { process.stdin.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]); buffer += chunk.toString('utf8');
while (true) { let newlineIndex: number;
const headerEnd = buffer.indexOf('\r\n\r\n'); while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
if (headerEnd === -1) { const rawMessage = buffer.slice(0, newlineIndex).trim();
return; buffer = buffer.slice(newlineIndex + 1);
} if (!rawMessage) {
const header = buffer.slice(0, headerEnd).toString('utf8');
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
if (!lengthMatch) {
buffer = buffer.slice(headerEnd + 4);
continue; 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 () => { void (async () => {
let request: JsonRpcRequest; let request: JsonRpcRequest;
try { try {

View File

@@ -8,7 +8,7 @@
* (no args) - Start the server (default) * (no args) - Start the server (default)
* start - Start the server * start - Start the server
* sandbox - Manage Docker sandbox environments * 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 * status - Show configuration and data locations
* help - Show help information * help - Show help information
* version - Show version information * version - Show version information
@@ -157,7 +157,7 @@ Usage:
Commands: Commands:
start Start the CloudCLI server (default) start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments 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 status Show configuration and data locations
update Update to the latest version update Update to the latest version
help Show this help information help Show this help information

View File

@@ -199,10 +199,10 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected) // Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes); 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); app.use('/api/browser-use-mcp', browserUseMcpRoutes);
// Browser Use API Routes (protected) // Browser API Routes (protected)
app.use('/api/browser-use', authenticateToken, browserUseRoutes); app.use('/api/browser-use', authenticateToken, browserUseRoutes);
// Computer Use MCP bridge API (local token protected) // Computer Use MCP bridge API (local token protected)
@@ -1763,7 +1763,7 @@ async function startServer() {
try { try {
await browserUseService.stopAllSessions(); await browserUseService.stopAllSessions();
} catch (err) { } 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 { try {
await computerUseService.stopAllSessions(); await computerUseService.stopAllSessions();

View File

@@ -16,7 +16,7 @@ router.use((req, res, next) => {
const expected = browserUseService.getMcpToken(); const expected = browserUseService.getMcpToken();
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || ''); const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
if (!token || token !== expected) { 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; return;
} }
next(); next();
@@ -104,7 +104,7 @@ router.post('/tools/:toolName', async (req, res) => {
result = await browserUseService.agentStopSession(sessionId); result = await browserUseService.agentStopSession(sessionId);
break; break;
default: 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; return;
} }
@@ -112,7 +112,7 @@ router.post('/tools/:toolName', async (req, res) => {
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
success: false, 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(); 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 { function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || ''; return Array.isArray(value) ? value[0] || '' : value || '';
} }
@@ -28,7 +14,7 @@ router.get('/status', async (_req, res) => {
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
success: false, 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) { } catch (error) {
res.status(500).json({ res.status(500).json({
success: false, 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) { } catch (error) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to save Browser Use settings.', error: error instanceof Error ? error.message : 'Failed to save Browser 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.',
}); });
} }
}); });
@@ -79,14 +53,14 @@ router.post('/runtime/install', async (_req, res) => {
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
success: false, 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 { try {
res.json({ success: true, data: { sessions: await browserUseService.listSessions(requireUser(req)) } }); res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
} catch (error) { } catch (error) {
res.status(401).json({ res.status(401).json({
success: false, 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 { try {
const session = await browserUseService.createSession(requireUser(req)); const result = await browserUseService.stopSession(readParam(req.params.sessionId));
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));
res.json({ success: true, data: result }); res.json({ success: true, data: result });
} catch (error) { } catch (error) {
res.status(400).json({ 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 { 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 }); res.json({ success: true, data: result });
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({

View File

@@ -1,14 +1,12 @@
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import dns from 'node:dns/promises';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import net from 'node:net';
import path from 'node:path'; import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js'; 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'; import { getModuleDir } from '@/utils/runtime-paths.js';
const require = createRequire(import.meta.url); 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 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 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 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_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token'; const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
@@ -26,7 +23,7 @@ type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type BrowserUseSession = { type BrowserUseSession = {
id: string; id: string;
ownerId: string; ownerId: string;
createdBy: 'user' | 'agent'; createdBy: 'agent';
runtime: BrowserUseRuntime; runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus; status: BrowserUseSessionStatus;
url: string | null; url: string | null;
@@ -36,7 +33,6 @@ type BrowserUseSession = {
updatedAt: string; updatedAt: string;
lastAction: string | null; lastAction: string | null;
message: string | null; message: string | null;
agentAccessEnabled: boolean;
profileName: string | null; profileName: string | null;
viewport: { viewport: {
width: number; width: number;
@@ -45,7 +41,7 @@ type BrowserUseSession = {
cursor: { cursor: {
x: number; x: number;
y: number; y: number;
actor: 'agent' | 'user'; actor: 'agent';
} | null; } | null;
}; };
@@ -57,13 +53,8 @@ type RuntimeHandle = {
page?: any; page?: any;
}; };
type BrowserUseOwner = {
id: string | number;
};
type BrowserUseSettings = { type BrowserUseSettings = {
enabled: boolean; enabled: boolean;
agentToolsEnabled: boolean;
}; };
type RuntimeReadiness = { type RuntimeReadiness = {
@@ -75,19 +66,22 @@ type RuntimeReadiness = {
installMessage: string | null; installMessage: string | null;
}; };
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
const sessions = new Map<string, BrowserUseSession>(); const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>(); const handles = new Map<string, RuntimeHandle>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null; let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null; let lastInstallMessage: string | null = null;
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = { const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: false, enabled: false,
agentToolsEnabled: false,
}; };
const AGENT_OWNER_ID = 'agent'; const AGENT_OWNER_ID = 'agent';
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles'); const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
const MCP_SERVER_NAME = 'cloudcli-browser-use'; const MCP_SERVER_NAME = 'cloudcli-browser';
const MCP_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'opencode']; const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
function getRuntime(): BrowserUseRuntime { function getRuntime(): BrowserUseRuntime {
return IS_PLATFORM ? 'cloud' : 'local'; return IS_PLATFORM ? 'cloud' : 'local';
@@ -103,10 +97,9 @@ function readSettings(): BrowserUseSettings {
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>; const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return { return {
enabled: parsed.enabled === true, enabled: parsed.enabled === true,
agentToolsEnabled: parsed.agentToolsEnabled === true,
}; };
} catch (error: any) { } 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; return DEFAULT_SETTINGS;
} }
} }
@@ -114,7 +107,6 @@ function readSettings(): BrowserUseSettings {
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings { function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = { const normalized = {
enabled: settings.enabled === true, enabled: settings.enabled === true,
agentToolsEnabled: settings.agentToolsEnabled === true,
}; };
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized)); appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
@@ -133,7 +125,7 @@ function getOrCreateMcpToken(): string {
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string { function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) { if (!settings.enabled) {
return 'Browser Use is disabled in settings.'; return 'Browser is disabled in settings.';
} }
if (!readiness.playwrightInstalled) { 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 '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 { function getPlaywright(): any | null {
@@ -176,6 +168,14 @@ function getMcpApiUrl(): string {
return `http://127.0.0.1:${port}/api/browser-use-mcp`; 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 { function normalizeProfileName(profileName?: string | null): string | null {
const normalized = String(profileName || '').trim(); const normalized = String(profileName || '').trim();
if (!normalized) { if (!normalized) {
@@ -194,15 +194,13 @@ function getProfilePath(profileName: string): string {
return path.join(PROFILE_ROOT, safeName); return path.join(PROFILE_ROOT, safeName);
} }
function getRuntimeReadiness(): RuntimeReadiness { function probeRuntime(): RuntimeProbe {
const playwright = getPlaywright(); const playwright = getPlaywright();
const readiness: RuntimeReadiness = { const readiness: RuntimeProbe = {
playwright, playwright,
playwrightInstalled: Boolean(playwright), playwrightInstalled: Boolean(playwright),
chromiumInstalled: false, chromiumInstalled: false,
chromiumExecutablePath: null, chromiumExecutablePath: null,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
}; };
if (!playwright) { if (!playwright) {
@@ -220,6 +218,26 @@ function getRuntimeReadiness(): RuntimeReadiness {
return readiness; 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( const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000), process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
10, 10,
@@ -244,7 +262,6 @@ function runCommand(command: string, args: string[]): Promise<void> {
fn(); fn();
}; };
// Guard against a stuck npm/playwright process hanging the install request forever.
const timer = setTimeout(() => { const timer = setTimeout(() => {
child.kill('SIGKILL'); child.kill('SIGKILL');
finish(() => reject(new Error( finish(() => reject(new Error(
@@ -272,7 +289,7 @@ function formatInstallError(error: unknown): string {
if (message.includes('sudo') && message.includes('password')) { 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 '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 }> { 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'; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
runtimeProbeCache = null;
installPromise = (async () => { installPromise = (async () => {
try { try {
lastInstallMessage = 'Installing Playwright package...'; lastInstallMessage = 'Installing Playwright package...';
@@ -294,7 +312,7 @@ async function installRuntime(): Promise<{ success: boolean; message: string }>
lastInstallMessage = 'Installing Chromium runtime...'; lastInstallMessage = 'Installing Chromium runtime...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']); await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
lastInstallMessage = 'Browser Use runtime installed.'; lastInstallMessage = 'Browser runtime installed.';
return { success: true, message: lastInstallMessage }; return { success: true, message: lastInstallMessage };
} catch (error) { } catch (error) {
lastInstallMessage = formatInstallError(error); lastInstallMessage = formatInstallError(error);
@@ -306,82 +324,11 @@ async function installRuntime(): Promise<{ success: boolean; message: string }>
return await installPromise; return await installPromise;
} finally { } finally {
installPromise = null; installPromise = null;
runtimeProbeCache = null;
} }
} }
function getOwnerId(owner: BrowserUseOwner): string { function normalizeUrl(rawUrl: string): 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> {
const trimmed = rawUrl.trim(); const trimmed = rawUrl.trim();
if (!trimmed) { if (!trimmed) {
throw new Error('URL is required.'); 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.'); throw new Error('Only http and https URLs are supported.');
} }
await assertPublicHttpTarget(parsed);
return parsed.toString(); 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 { function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
const { ownerId: _ownerId, ...publicFields } = session; const { ownerId: _ownerId, ...publicFields } = session;
return publicFields; return publicFields;
@@ -431,10 +354,6 @@ function ownerSessions(ownerId: string): BrowserUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId); 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> { async function closeHandle(sessionId: string): Promise<void> {
const handle = handles.get(sessionId); const handle = handles.get(sessionId);
handles.delete(sessionId); handles.delete(sessionId);
@@ -504,21 +423,15 @@ export const browserUseService = {
async updateSettings(settings: Partial<BrowserUseSettings>) { async updateSettings(settings: Partial<BrowserUseSettings>) {
const current = readSettings(); const current = readSettings();
const nextSettings = { const nextSettings = {
...current,
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled, 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); const next = writeSettings(nextSettings);
if (next.agentToolsEnabled) { if (next.enabled) {
await this.registerAgentMcp(); await this.registerAgentMcp();
} else if (current.agentToolsEnabled) { } else if (current.enabled) {
await this.unregisterAgentMcp(); await this.unregisterAgentMcp();
await this.stopAllSessions();
} }
return next; return next;
}, },
@@ -536,16 +449,15 @@ export const browserUseService = {
chromiumInstalled: readiness.chromiumInstalled, chromiumInstalled: readiness.chromiumInstalled,
installInProgress: readiness.installInProgress, installInProgress: readiness.installInProgress,
sessionCount: sessions.size, sessionCount: sessions.size,
agentToolsEnabled: settings.agentToolsEnabled,
mcpRecommended: !settings.agentToolsEnabled,
message: available message: available
? 'Browser Use runtime is available.' ? 'Browser runtime is available.'
: getSetupMessage(settings, readiness), : getSetupMessage(settings, readiness),
}; };
}, },
async registerAgentMcp() { async registerAgentMcp() {
const { command, args } = getMcpCommand(); const { command, args } = getMcpCommand();
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
const results = await providerMcpService.addMcpServerToAllProviders({ const results = await providerMcpService.addMcpServerToAllProviders({
name: MCP_SERVER_NAME, name: MCP_SERVER_NAME,
scope: 'user', scope: 'user',
@@ -565,21 +477,9 @@ export const browserUseService = {
}, },
async unregisterAgentMcp() { async unregisterAgentMcp() {
const results = await Promise.all(MCP_PROVIDERS.map(async (provider) => { const results = (await Promise.all(
try { [MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
const result = await providerMcpService.removeProviderMcpServer(provider, { )).flat();
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',
};
}
}));
return { name: MCP_SERVER_NAME, results }; return { name: MCP_SERVER_NAME, results };
}, },
@@ -591,25 +491,27 @@ export const browserUseService = {
}; };
}, },
async listSessions(owner: BrowserUseOwner) { async listSessions() {
const ownerId = getOwnerId(owner);
await expireStaleSessions(); await expireStaleSessions();
return [...sessions.values()] return [...sessions.values()]
.filter((session) => canAccessSession(ownerId, session)) .filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession); .map(publicSession);
}, },
async createSession(owner: BrowserUseOwner, options?: { createdBy?: 'user' | 'agent'; profileName?: string | null; agentAccessEnabled?: boolean }) { async createAgentSession(options?: { profileName?: string | null }) {
const ownerId = getOwnerId(owner); const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
await expireStaleSessions(); await expireStaleSessions();
const createdBy = options?.createdBy ?? 'user';
const profileName = normalizeProfileName(options?.profileName); const profileName = normalizeProfileName(options?.profileName);
const now = new Date().toISOString(); const now = new Date().toISOString();
const session: BrowserUseSession = { const session: BrowserUseSession = {
id: randomUUID(), id: randomUUID(),
ownerId, ownerId: AGENT_OWNER_ID,
createdBy, createdBy: 'agent',
runtime: getRuntime(), runtime: getRuntime(),
status: 'unavailable', status: 'unavailable',
url: null, url: null,
@@ -619,18 +521,16 @@ export const browserUseService = {
updatedAt: now, updatedAt: now,
lastAction: 'create', lastAction: 'create',
message: null, message: null,
agentAccessEnabled: options?.agentAccessEnabled ?? createdBy === 'agent',
profileName, profileName,
viewport: { width: 1440, height: 900 }, viewport: { width: 1440, height: 900 },
cursor: null, 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) { 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(); const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) { if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
session.message = getSetupMessage(settings, readiness); session.message = getSetupMessage(settings, readiness);
@@ -662,7 +562,6 @@ export const browserUseService = {
context = await browser.newContext(contextOptions); context = await browser.newContext(contextOptions);
page = await context.newPage(); page = await context.newPage();
} }
await attachRequestGuard(context);
session.status = 'ready'; session.status = 'ready';
session.message = 'Browser session is ready.'; session.message = 'Browser session is ready.';
sessions.set(session.id, session); sessions.set(session.id, session);
@@ -671,70 +570,35 @@ export const browserUseService = {
return publicSession(session); 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() { async listAgentSessions() {
const settings = readSettings(); const settings = readSettings();
if (!settings.enabled || !settings.agentToolsEnabled) { if (!settings.enabled) {
return []; return [];
} }
await expireStaleSessions(); await expireStaleSessions();
return [...sessions.values()] return [...sessions.values()]
.filter((session) => session.agentAccessEnabled || session.ownerId === AGENT_OWNER_ID) .filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession); .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) { async getAgentSession(sessionId: string) {
const settings = readSettings(); const settings = readSettings();
if (!settings.enabled || !settings.agentToolsEnabled) { if (!settings.enabled) {
throw new Error('Browser Use agent tools are disabled.'); throw new Error('Browser agent tools are disabled.');
} }
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
if (!session || (!session.agentAccessEnabled && session.ownerId !== AGENT_OWNER_ID)) { if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session is not shared with agents.'); throw new Error('Browser session not found.');
} }
return session; return session;
}, },
async navigate(owner: BrowserUseOwner, sessionId: string, rawUrl: string) { async agentNavigate(sessionId: string, rawUrl: string) {
const ownerId = getOwnerId(owner); await this.getAgentSession(sessionId);
await expireStaleSessions(); await expireStaleSessions();
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) { if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.'); throw new Error('Browser session not found.');
} }
@@ -747,7 +611,7 @@ export const browserUseService = {
throw new Error('Browser runtime handle is not available.'); 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 }); await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
session.lastAction = `navigate:${url}`; session.lastAction = `navigate:${url}`;
session.cursor = null; session.cursor = null;
@@ -755,25 +619,6 @@ export const browserUseService = {
return publicSession(session); 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) { async agentSnapshot(sessionId: string) {
const session = await this.getAgentSession(sessionId); const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId); const handle = handles.get(sessionId);
@@ -911,7 +756,6 @@ export const browserUseService = {
if (action === 'new') { if (action === 'new') {
const page = await handle.context.newPage(); const page = await handle.context.newPage();
handles.set(sessionId, { ...handle, page }); handles.set(sessionId, { ...handle, page });
// Request guard is attached at the context level, so new pages are already covered.
if (input.url) { if (input.url) {
await this.agentNavigate(sessionId, input.url); await this.agentNavigate(sessionId, input.url);
} }
@@ -942,10 +786,9 @@ export const browserUseService = {
}; };
}, },
async stopSession(owner: BrowserUseOwner, sessionId: string) { async stopSession(sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) { if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { stopped: false }; return { stopped: false };
} }
@@ -958,10 +801,9 @@ export const browserUseService = {
return { stopped: true, session: publicSession(session) }; return { stopped: true, session: publicSession(session) };
}, },
async deleteSession(owner: BrowserUseOwner, sessionId: string) { async deleteSession(sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) { if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { deleted: false }; return { deleted: false };
} }
@@ -970,52 +812,9 @@ export const browserUseService = {
return { deleted: true, sessionId }; 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) { async agentStopSession(sessionId: string) {
await this.getAgentSession(sessionId); await this.getAgentSession(sessionId);
return this.stopSession({ id: AGENT_OWNER_ID }, sessionId); return this.stopSession(sessionId);
}, },
async stopAllSessions() { async stopAllSessions() {

View File

@@ -1,30 +1,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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', () => { test('browser monitor list starts empty without agent sessions', async () => {
assert.equal(isBlockedBrowserUseAddress('127.0.0.1'), true); const sessions = await browserUseService.listSessions();
assert.equal(isBlockedBrowserUseAddress('10.0.0.12'), true);
assert.equal(isBlockedBrowserUseAddress('172.16.4.8'), true); assert.deepEqual(sessions, []);
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);
}); });

View File

@@ -1,5 +1,6 @@
export { sessionSynchronizerService } from './services/session-synchronizer.service.js'; export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
export { providerSkillsService } from './services/skills.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 { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
export { closeSessionsWatcher } 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; 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;
},
}; };

View File

@@ -71,7 +71,6 @@ function AppContentInner() {
setActiveTab, setActiveTab,
setSidebarOpen, setSidebarOpen,
setIsInputFocused, setIsInputFocused,
setShowSettings,
openSettings, openSettings,
refreshProjectsSilently, refreshProjectsSilently,
registerOptimisticSession, registerOptimisticSession,
@@ -247,7 +246,7 @@ function AppContentInner() {
onSessionEstablished={(targetSessionId, context) => onSessionEstablished={(targetSessionId, context) =>
registerOptimisticSession({ sessionId: targetSessionId, ...context }) registerOptimisticSession({ sessionId: targetSessionId, ...context })
} }
onShowSettings={() => setShowSettings(true)} onShowSettings={openSettings}
externalMessageUpdate={externalMessageUpdate} externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger} newSessionTrigger={newSessionTrigger}
/> />

View File

@@ -1,8 +1,23 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Bot, Download, Expand, ExternalLink, Globe, Loader2, MonitorPlay, Navigation, RefreshCw, Share2, Square, Trash2, X } from 'lucide-react'; import {
Bot,
Clock3,
Download,
Expand,
ExternalLink,
Loader2,
MonitorPlay,
RefreshCw,
Settings,
Square,
Trash2,
X,
} from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Badge, Button } from '../../../shared/view/ui'; import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import type { SettingsMainTab } from '../../settings/types/types';
type BrowserUseStatus = { type BrowserUseStatus = {
enabled: boolean; enabled: boolean;
@@ -11,8 +26,6 @@ type BrowserUseStatus = {
chromiumInstalled: boolean; chromiumInstalled: boolean;
installInProgress: boolean; installInProgress: boolean;
sessionCount: number; sessionCount: number;
agentToolsEnabled: boolean;
mcpRecommended: boolean;
message: string; message: string;
}; };
@@ -26,8 +39,7 @@ type BrowserUseSession = {
updatedAt: string; updatedAt: string;
lastAction: string | null; lastAction: string | null;
message: string | null; message: string | null;
agentAccessEnabled: boolean; createdBy: 'agent';
createdBy: 'user' | 'agent';
profileName: string | null; profileName: string | null;
viewport: { viewport: {
width: number; width: number;
@@ -36,12 +48,13 @@ type BrowserUseSession = {
cursor: { cursor: {
x: number; x: number;
y: number; y: number;
actor: 'agent' | 'user'; actor: 'agent';
} | null; } | null;
}; };
type BrowserUsePanelProps = { type BrowserUsePanelProps = {
isVisible: boolean; isVisible: boolean;
onShowSettings?: (tab?: SettingsMainTab) => void;
}; };
async function readJson<T>(response: Response): Promise<T> { async function readJson<T>(response: Response): Promise<T> {
@@ -52,48 +65,127 @@ async function readJson<T>(response: Response): Promise<T> {
return data as T; return data as T;
} }
export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) { function formatRelativeTime(value: string | null): string {
if (!value) return 'Never';
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return 'Unknown';
const elapsedSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
if (elapsedSeconds < 10) return 'Just now';
if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`;
const elapsedMinutes = Math.round(elapsedSeconds / 60);
if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
const elapsedHours = Math.round(elapsedMinutes / 60);
if (elapsedHours < 24) return `${elapsedHours}h ago`;
return `${Math.round(elapsedHours / 24)}d ago`;
}
function getDomain(url: string | null): string {
if (!url) return 'No page loaded';
try {
return new URL(url).hostname;
} catch {
return url;
}
}
function formatAction(action: string | null): string {
if (!action) return 'Waiting';
return action.replace(/_/g, ' ').replace(/:/g, ': ');
}
function getStatusTone(status: BrowserUseSession['status']): string {
if (status === 'ready') {
return 'border-primary/30 bg-primary/5 text-foreground';
}
if (status === 'stopped') {
return 'border-border bg-muted text-muted-foreground';
}
return 'border-border bg-background text-muted-foreground';
}
function getRuntimeTone(status: BrowserUseStatus | null, installing: boolean): string {
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
return 'border-border bg-background text-muted-foreground';
}
function getStatusDot(status: BrowserUseSession['status']): string {
if (status === 'ready') return 'bg-primary';
if (status === 'stopped') return 'bg-muted-foreground/50';
return 'bg-border';
}
const PROMPTS = [
'Use Browser to inspect the checkout flow and report any broken UI states.',
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
];
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) {
const [status, setStatus] = useState<BrowserUseStatus | null>(null); const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [sessions, setSessions] = useState<BrowserUseSession[]>([]); const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null); const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [targetUrl, setTargetUrl] = useState('https://example.com'); const [isRefreshing, setIsRefreshing] = useState(false);
const [isBusy, setIsBusy] = useState(false); const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const viewerRef = useRef<HTMLDivElement | null>(null);
const selectedSession = useMemo( const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null, () => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
[selectedSessionId, sessions], [selectedSessionId, sessions],
); );
const activeSessions = sessions.filter((session) => session.status === 'ready');
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
const runtimeLabel = !status?.enabled
? 'Disabled'
: status.available
? 'Ready'
: status.installInProgress || isInstalling
? 'Installing'
: 'Setup required';
const cursorStyle = selectedSession?.cursor && selectedSession.viewport
? {
left: `${(selectedSession.cursor.x / selectedSession.viewport.width) * 100}%`,
top: `${(selectedSession.cursor.y / selectedSession.viewport.height) * 100}%`,
}
: null;
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const [statusResponse, sessionsResponse] = await Promise.all([ setIsRefreshing(true);
authenticatedFetch('/api/browser-use/status'), try {
authenticatedFetch('/api/browser-use/sessions'), const [statusResponse, sessionsResponse] = await Promise.all([
]); authenticatedFetch('/api/browser-use/status'),
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse); authenticatedFetch('/api/browser-use/sessions'),
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse); ]);
setStatus(statusData.data); const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
setSessions(sessionsData.data.sessions); const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
setSelectedSessionId((current) => ( const nextSessions = sessionsData.data.sessions;
current && sessionsData.data.sessions.some((session) => session.id === current) setStatus(statusData.data);
? current setSessions(nextSessions);
: sessionsData.data.sessions[0]?.id || null setSelectedSessionId((current) => (
)); current && nextSessions.some((session) => session.id === current)
? current
: nextSessions[0]?.id || null
));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Browser');
} finally {
setIsRefreshing(false);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isVisible) return; if (!isVisible) return;
void refresh().catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser Use')); void refresh();
}, [isVisible, refresh]); }, [isVisible, refresh]);
useEffect(() => {
if (!selectedSession?.url) return;
setTargetUrl(selectedSession.url);
}, [selectedSession?.id, selectedSession?.url]);
const runAction = useCallback(async (action: () => Promise<void>) => { const runAction = useCallback(async (action: () => Promise<void>) => {
setIsBusy(true); setIsBusy(true);
setError(null); setError(null);
@@ -101,29 +193,12 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
await action(); await action();
await refresh(); await refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Browser Use action failed'); setError(err instanceof Error ? err.message : 'Browser action failed');
} finally { } finally {
setIsBusy(false); setIsBusy(false);
} }
}, [refresh]); }, [refresh]);
const createSession = () => runAction(async () => {
const response = await authenticatedFetch('/api/browser-use/sessions', { method: 'POST' });
const data = await readJson<{ data: { session: BrowserUseSession } }>(response);
setSelectedSessionId(data.data.session.id);
});
const navigate = () => runAction(async () => {
if (!selectedSession) {
throw new Error('Create a browser session first.');
}
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/navigate`, {
method: 'POST',
body: JSON.stringify({ url: targetUrl }),
});
await readJson(response);
});
const stopSession = () => runAction(async () => { const stopSession = () => runAction(async () => {
if (!selectedSession) return; if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' }); const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
@@ -137,18 +212,6 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
setIsFullscreen(false); setIsFullscreen(false);
}); });
const grantAgentAccess = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/agent-access/grant`, { method: 'POST' });
await readJson(response);
});
const revokeAgentAccess = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/agent-access/revoke`, { method: 'POST' });
await readJson(response);
});
const installBrowserBinaries = () => runAction(async () => { const installBrowserBinaries = () => runAction(async () => {
setIsInstalling(true); setIsInstalling(true);
try { try {
@@ -159,80 +222,108 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
} }
}); });
const clickViewer = useCallback((event: MouseEvent<HTMLImageElement>) => { const renderSessionItem = (session: BrowserUseSession) => {
if (!selectedSession || selectedSession.status !== 'ready' || !selectedSession.viewport) { const isSelected = selectedSession?.id === session.id;
return; return (
} <button
viewerRef.current?.focus(); key={session.id}
type="button"
onClick={() => setSelectedSessionId(session.id)}
className={cn(
'group w-full rounded-md border px-3 py-2.5 text-left transition-colors',
isSelected
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
<div className="truncate text-sm font-medium">{session.title || getDomain(session.url)}</div>
</div>
<div className="mt-1 truncate pl-3.5 text-xs text-muted-foreground">{getDomain(session.url)}</div>
</div>
<Badge variant="outline" className="shrink-0 border-border bg-background text-[10px] text-muted-foreground">
{session.status}
</Badge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Clock3 className="h-3 w-3" />
<span>{formatRelativeTime(session.updatedAt)}</span>
<span className="truncate">- {formatAction(session.lastAction)}</span>
</div>
</button>
);
};
const bounds = event.currentTarget.getBoundingClientRect(); const renderEmptyState = () => (
const scaleX = selectedSession.viewport.width / bounds.width; <div className="flex min-h-0 flex-1 items-center justify-center p-6">
const scaleY = selectedSession.viewport.height / bounds.height; <div className="w-full max-w-2xl rounded-md border border-border bg-card/40 p-5 shadow-sm">
const x = Math.round((event.clientX - bounds.left) * scaleX); <div className="flex items-start gap-3">
const y = Math.round((event.clientY - bounds.top) * scaleY); <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<MonitorPlay className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">
{status?.enabled ? 'No browser sessions yet' : 'Browser is disabled'}
</div>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
{status?.enabled
? 'Agent browser sessions appear here while an AI task is using Browser.'
: 'Enable Browser in settings to let agents open monitored browser sessions.'}
</p>
</div>
</div>
void runAction(async () => { {needsBrowserBinaries && (
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/click`, { <div className="mt-4 rounded-md border border-border bg-muted/30 p-3">
method: 'POST', <div className="text-sm font-medium text-foreground">Runtime setup required</div>
body: JSON.stringify({ x, y }), <p className="mt-1 text-sm text-muted-foreground">{status?.message}</p>
}); <Button
await readJson(response); type="button"
}); size="sm"
}, [runAction, selectedSession]); className="mt-3"
onClick={installBrowserBinaries}
disabled={isBusy || isInstalling || status?.installInProgress}
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
</Button>
</div>
)}
const keyForEvent = useCallback((event: KeyboardEvent<HTMLDivElement>) => { <div className="mt-5 grid gap-2 sm:grid-cols-2">
if (event.key === ' ') return 'Space'; {PROMPTS.map((prompt) => (
return event.key; <div key={prompt} className="rounded-md border border-border/70 bg-background/70 p-3">
}, []); <div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
const pressViewerKey = useCallback((event: KeyboardEvent<HTMLDivElement>) => { Prompt
if (!selectedSession || selectedSession.status !== 'ready') { </div>
return; <p className="text-sm leading-6 text-foreground">{prompt}</p>
} </div>
))}
const ignoredKeys = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']); </div>
if (ignoredKeys.has(event.key)) { </div>
return; </div>
} );
event.preventDefault();
const key = keyForEvent(event);
void runAction(async () => {
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/press-key`, {
method: 'POST',
body: JSON.stringify({ key }),
});
await readJson(response);
});
}, [keyForEvent, runAction, selectedSession]);
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
const cursorStyle = selectedSession?.cursor && selectedSession.viewport
? {
left: `${(selectedSession.cursor.x / selectedSession.viewport.width) * 100}%`,
top: `${(selectedSession.cursor.y / selectedSession.viewport.height) * 100}%`,
}
: null;
const renderBrowserSurface = (fullscreen = false) => ( const renderBrowserSurface = (fullscreen = false) => (
<div <div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
ref={viewerRef}
tabIndex={selectedSession?.status === 'ready' ? 0 : -1}
onKeyDown={pressViewerKey}
className={`flex min-h-[360px] flex-1 items-center justify-center bg-neutral-950 outline-none ${fullscreen ? 'min-h-[80vh]' : ''}`}
>
{selectedSession?.screenshotDataUrl ? ( {selectedSession?.screenshotDataUrl ? (
<div className="relative inline-block max-h-full"> <div className="relative inline-block max-h-full">
<img <img
src={selectedSession.screenshotDataUrl} src={selectedSession.screenshotDataUrl}
alt="Browser session screenshot" alt="Browser session screenshot"
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[70vh] w-auto max-w-full object-contain'} className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[72vh] w-auto max-w-full object-contain'}
onClick={clickViewer}
/> />
{cursorStyle && ( {cursorStyle && (
<div <div
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-sky-500/80 shadow-[0_0_0_6px_rgba(14,165,233,0.18)]" className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-primary/80 shadow-[0_0_0_6px_hsl(var(--primary)/0.18)]"
style={cursorStyle} style={cursorStyle}
> >
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" /> <div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
@@ -240,14 +331,10 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
)} )}
</div> </div>
) : ( ) : (
<div className="max-w-md px-6 text-center"> <div className="px-6 text-center">
<MonitorPlay className="mx-auto h-10 w-10 text-neutral-500" /> <MonitorPlay className="mx-auto h-9 w-9 text-neutral-500" />
<div className="mt-3 text-sm font-medium text-neutral-100"> <div className="mt-3 text-sm font-medium text-neutral-100">{selectedSession?.message || 'Waiting for screenshot'}</div>
{selectedSession?.message || 'Create a browser session to start.'} <p className="mt-1 text-xs text-neutral-400">The next agent browser snapshot will render here.</p>
</div>
<p className="mt-2 text-xs leading-relaxed text-neutral-400">
Install browser binaries from this panel or enable Browser Use from Settings.
</p>
</div> </div>
)} )}
</div> </div>
@@ -255,171 +342,184 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background"> <div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-4 py-3"> <div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MonitorPlay className="h-4 w-4 text-primary" /> <MonitorPlay className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Browser Use</h3> <h3 className="text-sm font-semibold text-foreground">Browser</h3>
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
{runtimeLabel}
</Badge>
</div> </div>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p>
Create browser sessions, watch agent activity, and decide which sessions agents may control.
</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center gap-1.5">
<a {onShowSettings && (
href="https://cloudcli.ai/docs/user-guide/browser-use" <Button
target="_blank" variant="ghost"
rel="noreferrer" size="sm"
className="inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground" className="h-7 w-7 p-0"
onClick={() => onShowSettings('browser')}
title="Open Browser settings"
aria-label="Open Browser settings"
>
<Settings className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => void refresh()}
disabled={isRefreshing || isBusy}
title="Refresh browser sessions"
aria-label="Refresh browser sessions"
> >
Guide <RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
<ExternalLink className="h-4 w-4" />
</a>
<Button variant="outline" size="sm" onClick={() => void refresh()} disabled={isBusy}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
<Button size="sm" onClick={createSession} disabled={isBusy || !status?.available}>
<Globe className="h-4 w-4" />
New Session
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)]"> {error && (
<aside className="border-b border-border/60 p-3 lg:border-b-0 lg:border-r"> <div className="border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{needsBrowserBinaries && ( {error}
<div className="rounded-lg border border-border/70 bg-card/40 p-3"> </div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Browser binaries required</div> )}
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{status?.message || 'Install the browser binaries needed to create Browser Use sessions.'}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
Playwright: {status?.playwrightInstalled ? 'installed' : 'missing'}
</span>
<span className="rounded-md border border-border px-2 py-1">
Chromium: {status?.chromiumInstalled ? 'installed' : 'missing'}
</span>
</div>
<Button
type="button"
size="sm"
className="mt-3 w-full"
onClick={installBrowserBinaries}
disabled={isBusy || isInstalling || status?.installInProgress}
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing…' : 'Install Binaries'}
</Button>
</div>
)}
<div className="mt-3 space-y-2"> {sessions.length > 0 && (
<div className="rounded-lg border border-border/70 bg-muted/30 p-3 text-xs leading-relaxed text-muted-foreground"> <div className="border-b border-border/60 bg-muted/20 px-3 py-2 lg:hidden">
Agents can create their own browser sessions when browser tools are enabled. Use <div className="flex gap-2 overflow-x-auto">
<span className="font-medium text-foreground"> Give Agent Access </span>
to let agents control a session you created, and revoke access whenever you want.
</div>
{sessions.map((session) => ( {sessions.map((session) => (
<button <button
key={session.id} key={session.id}
type="button" type="button"
onClick={() => setSelectedSessionId(session.id)} onClick={() => setSelectedSessionId(session.id)}
className={`w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors ${selectedSession?.id === session.id className={cn(
? 'border-primary/50 bg-primary/10 text-foreground' 'flex min-w-[180px] items-center gap-2 rounded-md border px-2.5 py-2 text-left',
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50' selectedSession?.id === session.id
}`} ? 'border-primary/40 bg-primary/5'
: 'border-border bg-background',
)}
> >
<div className="flex items-center justify-between gap-2"> <span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
<span className="truncate font-medium">{session.title || session.url || 'Browser session'}</span> <span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
<Badge variant="outline" className="text-[10px]">{session.status}</Badge> {session.title || getDomain(session.url)}
</div> </span>
<div className="mt-1 flex flex-wrap gap-1">
{session.agentAccessEnabled && (
<span className="rounded border border-emerald-500/30 px-1.5 py-0.5 text-[10px] text-emerald-600 dark:text-emerald-300">
shared
</span>
)}
</div>
<div className="mt-1 truncate text-xs">{session.url || session.message || session.id}</div>
</button> </button>
))} ))}
{sessions.length === 0 && ( </div>
<div className="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground"> </div>
No browser sessions yet. )}
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<main className="flex min-h-0 flex-col overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
<div className="min-w-0 truncate">
{activeSessions.length} active
<span className="px-1.5">/</span>
{sessions.length} total
</div>
<div className="min-w-0 truncate">
Updated {formatRelativeTime(selectedSession?.updatedAt || null)}
</div>
</div>
{sessions.length === 0 ? (
renderEmptyState()
) : (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<Badge variant="outline" className={selectedSession ? cn('text-[10px]', getStatusTone(selectedSession.status)) : 'text-[10px]'}>
{selectedSession?.status || 'empty'}
</Badge>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{selectedSession?.title || getDomain(selectedSession?.url || null)}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
</div>
</div>
<div className="hidden text-xs text-muted-foreground md:block">
{formatAction(selectedSession?.lastAction || null)}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
<Expand className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session">
<Square className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{renderBrowserSurface()}
</div> </div>
)}
</div>
</aside>
<main className="flex min-h-0 flex-col">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<input
value={targetUrl}
onChange={(event) => setTargetUrl(event.target.value)}
className="h-9 min-w-[220px] flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
placeholder="https://example.com"
/>
<Button variant="outline" size="sm" onClick={navigate} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Navigation className="h-4 w-4" />
Go
</Button>
{selectedSession?.agentAccessEnabled ? (
<Button variant="outline" size="sm" onClick={revokeAgentAccess} disabled={isBusy || !selectedSession}>
<X className="h-4 w-4" />
Revoke Agent
</Button>
) : (
<Button variant="outline" size="sm" onClick={grantAgentAccess} disabled={isBusy || !selectedSession || !status?.agentToolsEnabled}>
<Share2 className="h-4 w-4" />
Give Agent Access
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl}>
<Expand className="h-4 w-4" />
Full Screen
</Button>
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Square className="h-4 w-4" />
Stop
</Button>
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
{error && (
<div className="border-b border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div> </div>
)} )}
</main>
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4"> <aside className="hidden min-h-0 flex-col border-l border-border/60 bg-background lg:flex">
<div className="mx-auto flex min-h-[420px] max-w-6xl flex-col overflow-hidden rounded-lg border border-border bg-background shadow-sm"> <div className="border-b border-border/60 px-4 py-3">
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground"> <div className="flex items-center justify-between gap-2">
<ExternalLink className="h-3.5 w-3.5" /> <div>
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span> <div className="text-sm font-semibold text-foreground">Sessions</div>
{selectedSession?.agentAccessEnabled && ( <div className="mt-0.5 text-xs text-muted-foreground">{sessions.length} total</div>
<span className="ml-auto inline-flex items-center gap-1 rounded border border-emerald-500/30 px-2 py-0.5 text-emerald-600 dark:text-emerald-300">
<Bot className="h-3.5 w-3.5" />
Agent access active
</span>
)}
</div> </div>
{renderBrowserSurface()} <Badge variant="outline" className="text-[10px]">{activeSessions.length} active</Badge>
</div> </div>
</div> </div>
</main>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{sessions.length > 0 ? (
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
) : (
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
No agent browser sessions.
</div>
)}
</div>
<div className="border-t border-border/60 p-3">
<div className="rounded-md border border-border/70 bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Selected
</div>
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<span>Status</span>
<span className="font-medium text-foreground">{selectedSession?.status || 'None'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Last action</span>
<span className="truncate font-medium text-foreground">{formatAction(selectedSession?.lastAction || null)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Profile</span>
<span className="truncate font-medium text-foreground">{selectedSession?.profileName || 'Temporary'}</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Square className="h-4 w-4" />
Stop
</Button>
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
</aside>
</div> </div>
{isFullscreen && selectedSession && ( {isFullscreen && selectedSession && (
<div className="fixed inset-0 z-50 bg-black/90 p-6"> <div className="fixed inset-0 z-50 bg-black/90 p-6">
<div className="flex h-full flex-col rounded-lg border border-white/10 bg-black"> <div className="flex h-full flex-col rounded-md border border-white/10 bg-black">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80"> <div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
<div className="min-w-0 truncate">{selectedSession.title || selectedSession.url || 'Browser session'}</div> <div className="min-w-0 truncate">{selectedSession.title || selectedSession.url || 'Browser session'}</div>
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}> <Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>

View File

@@ -7,6 +7,7 @@ import type {
SessionActivityMap, SessionActivityMap,
} from '../../../hooks/useSessionProtection'; } from '../../../hooks/useSessionProtection';
import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types'; import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types';
import type { SettingsMainTab } from '../../settings/types/types';
export type TaskMasterTask = { export type TaskMasterTask = {
id: string | number; id: string | number;
@@ -53,7 +54,7 @@ export type MainContentProps = {
processingSessions: SessionActivityMap; processingSessions: SessionActivityMap;
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void; onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void; onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void;
onShowSettings: () => void; onShowSettings: (tab?: SettingsMainTab) => void;
externalMessageUpdate: number; externalMessageUpdate: number;
newSessionTrigger: number; newSessionTrigger: number;
}; };

View File

@@ -226,7 +226,7 @@ function MainContent({
{shouldShowBrowserTab && activeTab === 'browser' && ( {shouldShowBrowserTab && activeTab === 'browser' && (
<div className="h-full overflow-hidden"> <div className="h-full overflow-hidden">
<BrowserUsePanel isVisible={activeTab === 'browser'} /> <BrowserUsePanel isVisible={activeTab === 'browser'} onShowSettings={onShowSettings} />
</div> </div>
)} )}

View File

@@ -29,7 +29,7 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
} }
if (activeTab === 'browser') { if (activeTab === 'browser') {
return 'Browser Use'; return 'Browser';
} }
if (activeTab === 'computer') { if (activeTab === 'computer') {

View File

@@ -52,6 +52,11 @@ const getServerKey = (server: ProviderMcpServer): string => (
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}` `${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
); );
// Servers prefixed with `cloudcli-` are written and removed automatically by a
// CloudCLI feature toggle (e.g. the Browser tab), not added by the user. They are
// shown read-only so users don't edit/delete them out of sync with the feature.
const isManagedServer = (server: ProviderMcpServer): boolean => server.name.startsWith('cloudcli-');
function ConfigLine({ label, children }: { label: string; children: string }) { function ConfigLine({ label, children }: { label: string; children: string }) {
if (!children) { if (!children) {
return null; return null;
@@ -177,65 +182,92 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div> <div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
)} )}
{servers.map((server) => ( {servers.map((server) => {
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4"> const managed = isManagedServer(server);
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1"> return (
<div className="mb-2 flex flex-wrap items-center gap-2"> <div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
{getTransportIcon(server.transport)} <div className="flex items-start justify-between">
<span className="font-medium text-foreground">{server.name}</span> <div className="min-w-0 flex-1">
<Badge variant="outline" className="text-xs"> <div className="mb-2 flex flex-wrap items-center gap-2">
{server.transport || 'stdio'} {!managed && getTransportIcon(server.transport)}
</Badge> <span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs"> {!managed && (
{getScopeLabel(server.scope)} <>
</Badge> <Badge variant="outline" className="text-xs">
{server.projectDisplayName && ( {server.transport || 'stdio'}
<Badge variant="outline" className="max-w-full truncate text-xs"> </Badge>
{server.projectDisplayName} <Badge variant="outline" className="text-xs">
</Badge> {getScopeLabel(server.scope)}
)} </Badge>
{server.projectDisplayName && (
<Badge variant="outline" className="max-w-full truncate text-xs">
{server.projectDisplayName}
</Badge>
)}
</>
)}
{managed && (
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
{t('mcpServers.managed.badge', { defaultValue: 'Managed' })}
</Badge>
)}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{!managed && (
<>
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
{server.env && Object.keys(server.env).length > 0 && (
<ConfigLine label={t('mcpServers.config.environment')}>
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</ConfigLine>
)}
{server.envVars && server.envVars.length > 0 && (
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
)}
</>
)}
{managed && (
<div className="text-xs text-muted-foreground">
{t('mcpServers.managed.hint', {
defaultValue: 'Managed by CloudCLI.',
})}
</div>
)}
</div>
</div> </div>
<div className="space-y-1 text-sm text-muted-foreground"> {!managed && (
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine> <div className="ml-4 flex items-center gap-2">
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine> <Button
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine> onClick={() => openForm(server)}
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine> variant="ghost"
{server.env && Object.keys(server.env).length > 0 && ( size="sm"
<ConfigLine label={t('mcpServers.config.environment')}> className="text-muted-foreground hover:text-foreground"
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')} title={t('mcpServers.actions.edit')}
</ConfigLine> >
)} <Edit3 className="h-4 w-4" />
{server.envVars && server.envVars.length > 0 && ( </Button>
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine> <Button
)} onClick={() => deleteServer(server)}
</div> variant="ghost"
</div> size="sm"
className="text-red-600 hover:text-red-700"
<div className="ml-4 flex items-center gap-2"> title={t('mcpServers.actions.delete')}
<Button >
onClick={() => openForm(server)} <Trash2 className="h-4 w-4" />
variant="ghost" </Button>
size="sm" </div>
className="text-muted-foreground hover:text-foreground" )}
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => deleteServer(server)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</div> );
))} })}
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && ( {!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div> <div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>

View File

@@ -33,7 +33,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch }, { id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch },
{ id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound }, { id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound },
{ id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks }, { id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks },
{ id: 'browser', label: 'Browser Use', keywords: 'browser use playwright chromium automation', icon: MonitorPlay }, { id: 'browser', label: 'Browser', keywords: 'browser playwright chromium automation', icon: MonitorPlay },
{ id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell }, { id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell },
{ id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug }, { id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug },
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info }, { id: 'about', label: 'About', keywords: 'about version info', icon: Info },

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Download, ExternalLink, Loader2 } from 'lucide-react'; import { Download, Loader2 } from 'lucide-react';
import { Button } from '../../../../../shared/view/ui'; import { Button } from '../../../../../shared/view/ui';
import { authenticatedFetch } from '../../../../../utils/api'; import { authenticatedFetch } from '../../../../../utils/api';
@@ -10,7 +10,6 @@ import SettingsToggle from '../../SettingsToggle';
type BrowserUseSettings = { type BrowserUseSettings = {
enabled: boolean; enabled: boolean;
agentToolsEnabled: boolean;
}; };
type BrowserUseStatus = { type BrowserUseStatus = {
@@ -19,7 +18,6 @@ type BrowserUseStatus = {
playwrightInstalled: boolean; playwrightInstalled: boolean;
chromiumInstalled: boolean; chromiumInstalled: boolean;
installInProgress: boolean; installInProgress: boolean;
agentToolsEnabled: boolean;
message: string; message: string;
}; };
@@ -32,31 +30,39 @@ async function readJson<T>(response: Response): Promise<T> {
} }
export default function BrowserUseSettingsTab() { export default function BrowserUseSettingsTab() {
const [settings, setSettings] = useState<BrowserUseSettings>({ enabled: false, agentToolsEnabled: false }); const [settings, setSettings] = useState<BrowserUseSettings | null>(null);
const [status, setStatus] = useState<BrowserUseStatus | null>(null); const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isSettingsLoading, setIsSettingsLoading] = useState(true);
const [isStatusLoading, setIsStatusLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadState = useCallback(async () => { const loadSettings = useCallback(async () => {
setError(null); const settingsResponse = await authenticatedFetch('/api/browser-use/settings');
const [settingsResponse, statusResponse] = await Promise.all([
authenticatedFetch('/api/browser-use/settings'),
authenticatedFetch('/api/browser-use/status'),
]);
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse); const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
setSettings(settingsData.data.settings); setSettings(settingsData.data.settings);
}, []);
const loadStatus = useCallback(async () => {
const statusResponse = await authenticatedFetch('/api/browser-use/status');
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
setStatus(statusData.data); setStatus(statusData.data);
}, []); }, []);
useEffect(() => { useEffect(() => {
setIsLoading(true); setError(null);
void loadState() setIsSettingsLoading(true);
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser Use settings')) setIsStatusLoading(true);
.finally(() => setIsLoading(false));
}, [loadState]); void loadSettings()
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser settings'))
.finally(() => setIsSettingsLoading(false));
void loadStatus()
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser status'))
.finally(() => setIsStatusLoading(false));
}, [loadSettings, loadStatus]);
const updateSettings = async (nextSettings: Partial<BrowserUseSettings>) => { const updateSettings = async (nextSettings: Partial<BrowserUseSettings>) => {
setIsSaving(true); setIsSaving(true);
@@ -69,10 +75,12 @@ export default function BrowserUseSettingsTab() {
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response); const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
setSettings(data.data.settings); setSettings(data.data.settings);
window.dispatchEvent(new Event('browserUseSettingsChanged')); window.dispatchEvent(new Event('browserUseSettingsChanged'));
await loadState(); setIsStatusLoading(true);
await loadStatus();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save Browser Use settings'); setError(err instanceof Error ? err.message : 'Failed to save Browser settings');
} finally { } finally {
setIsStatusLoading(false);
setIsSaving(false); setIsSaving(false);
} }
}; };
@@ -83,108 +91,93 @@ export default function BrowserUseSettingsTab() {
try { try {
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' }); const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
await readJson(response); await readJson(response);
await loadState(); setIsStatusLoading(true);
await loadStatus();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to install browser binaries'); setError(err instanceof Error ? err.message : 'Failed to install browser runtime');
} finally { } finally {
setIsStatusLoading(false);
setIsInstalling(false); setIsInstalling(false);
} }
}; };
const needsBrowserBinaries = Boolean(settings.enabled && status && (!status.playwrightInstalled || !status.chromiumInstalled)); const browserEnabled = settings?.enabled === true;
const needsBrowserBinaries = Boolean(browserEnabled && status && (!status.playwrightInstalled || !status.chromiumInstalled));
const runtimeLabel = (installed?: boolean) => {
if (isStatusLoading && !status) {
return 'checking...';
}
return installed ? 'installed' : 'missing';
};
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SettingsSection <SettingsSection
title="Browser Use" title="Browser"
description="Manage local Playwright browser sessions used for captured browser screenshots and guarded navigation." description="Allow agents to create guarded Playwright browser sessions that you can monitor from the Browser tab."
> >
<SettingsCard divided> <SettingsCard divided>
<div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between"> <SettingsRow
<div className="min-w-0"> label="Enable Browser"
<div className="text-sm font-medium text-foreground">How Browser Use Works</div> description="Registers Browser for supported agents. Agents can create browser sessions; you can watch, stop, and delete them."
<p className="mt-0.5 text-sm text-muted-foreground"> >
Learn what agents can do with browser sessions, when to share access, and what the current limitations are. {isSettingsLoading && !settings ? (
</p> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<SettingsToggle
checked={browserEnabled}
onChange={(value) => void updateSettings({ enabled: value })}
ariaLabel="Enable Browser"
disabled={isSaving}
/>
)}
</SettingsRow>
<div className="space-y-4 px-4 py-4">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
Playwright: {runtimeLabel(status?.playwrightInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
Chromium: {runtimeLabel(status?.chromiumInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'}
</span>
</div> </div>
<a
href="https://cloudcli.ai/docs/user-guide/browser-use" {needsBrowserBinaries && (
target="_blank" <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
rel="noreferrer" <div className="min-w-0 space-y-1">
className="inline-flex h-9 flex-shrink-0 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground" <div className="text-sm font-medium text-foreground">Browser runtime required</div>
> <p className="text-sm text-muted-foreground">
Open Guide {status?.message || 'Install the browser runtime before agents can create Browser sessions.'}
<ExternalLink className="h-4 w-4" /> </p>
</a> </div>
<Button
type="button"
size="sm"
onClick={() => void installBrowserBinaries()}
disabled={isInstalling || status?.installInProgress}
className="flex-shrink-0"
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
</Button>
</div>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
)}
</div> </div>
<SettingsRow
label="Enable Browser Use"
description="Allow CloudCLI to create owner-scoped Playwright browser sessions."
>
<SettingsToggle
checked={settings.enabled}
onChange={(value) => void updateSettings({ enabled: value })}
ariaLabel="Enable Browser Use"
disabled={isLoading || isSaving}
/>
</SettingsRow>
<SettingsRow
label="Enable Browser Tools for Agents"
description="Register the Browser Use MCP server for all agent providers. Agents can create browser sessions and control sessions shared with agents."
>
<SettingsToggle
checked={settings.agentToolsEnabled}
onChange={(value) => void updateSettings({ agentToolsEnabled: value })}
ariaLabel="Enable Browser Tools for Agents"
disabled={isLoading || isSaving || !settings.enabled}
/>
</SettingsRow>
{(needsBrowserBinaries || error) && (
<div className="space-y-4 px-4 py-4">
{needsBrowserBinaries && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium text-foreground">Browser binaries required</div>
<p className="text-sm text-muted-foreground">
{status?.message || 'Install the browser binaries needed to create Browser Use sessions.'}
</p>
<div className="flex flex-wrap gap-2 pt-1 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
Playwright: {status?.playwrightInstalled ? 'installed' : 'missing'}
</span>
<span className="rounded-md border border-border px-2 py-1">
Chromium: {status?.chromiumInstalled ? 'installed' : 'missing'}
</span>
</div>
</div>
<Button
type="button"
size="sm"
onClick={() => void installBrowserBinaries()}
disabled={isInstalling || status?.installInProgress}
className="flex-shrink-0"
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing…' : 'Install Binaries'}
</Button>
</div>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
)}
</div>
)}
</SettingsCard> </SettingsCard>
</SettingsSection> </SettingsSection>
</div> </div>

View File

@@ -69,7 +69,7 @@ export default function SidebarFooter({
onClick={onShowVersionModal} onClick={onShowVersionModal}
> >
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<ArrowUpCircle className="w-4.5 h-4.5 text-blue-500 dark:text-blue-400" /> <ArrowUpCircle className="h-4 w-4 text-blue-500 dark:text-blue-400" />
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" /> <span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
</div> </div>
<div className="min-w-0 flex-1 text-left"> <div className="min-w-0 flex-1 text-left">
@@ -145,12 +145,12 @@ export default function SidebarFooter({
href={GITHUB_ISSUES_URL} href={GITHUB_ISSUES_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]" className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]"
> >
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80"> <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
<Bug className="w-4.5 h-4.5 text-muted-foreground" /> <Bug className="h-4 w-4 text-muted-foreground" />
</div> </div>
<span className="text-base font-medium text-foreground">{t('actions.reportIssue')}</span> <span className="text-sm font-medium text-foreground">{t('actions.reportIssue')}</span>
</a> </a>
</div> </div>
@@ -160,25 +160,25 @@ export default function SidebarFooter({
href={DISCORD_INVITE_URL} href={DISCORD_INVITE_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]" className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]"
> >
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80"> <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
<DiscordIcon className="w-4.5 h-4.5 text-muted-foreground" /> <DiscordIcon className="h-4 w-4 text-muted-foreground" />
</div> </div>
<span className="text-base font-medium text-foreground">{t('actions.joinCommunity')}</span> <span className="text-sm font-medium text-foreground">{t('actions.joinCommunity')}</span>
</a> </a>
</div> </div>
{/* Mobile settings */} {/* Mobile settings */}
<div className="px-3 pb-3 pt-2 md:hidden"> <div className="px-3 pb-3 pt-2 md:hidden">
<button <button
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]" className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]"
onClick={onShowSettings} onClick={onShowSettings}
> >
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80"> <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
<Settings className="w-4.5 h-4.5 text-muted-foreground" /> <Settings className="h-4 w-4 text-muted-foreground" />
</div> </div>
<span className="text-base font-medium text-foreground">{t('actions.settings')}</span> <span className="text-sm font-medium text-foreground">{t('actions.settings')}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -94,7 +94,7 @@
"git": "Git", "git": "Git",
"apiTokens": "API & Tokens", "apiTokens": "API & Tokens",
"tasks": "Tasks", "tasks": "Tasks",
"browser": "Browser Use", "browser": "Browser",
"computer": "Computer Use", "computer": "Computer Use",
"notifications": "Notifications", "notifications": "Notifications",
"plugins": "Plugins", "plugins": "Plugins",
@@ -452,6 +452,10 @@
"edit": "Edit server", "edit": "Edit server",
"delete": "Delete server" "delete": "Delete server"
}, },
"managed": {
"badge": "Managed",
"hint": "Managed by CloudCLI."
},
"help": { "help": {
"title": "About Codex MCP", "title": "About Codex MCP",
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources." "description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."