mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-16 20:32:00 +08:00
1013 lines
33 KiB
TypeScript
1013 lines
33 KiB
TypeScript
import { createRequire } from 'node:module';
|
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
import { spawn } from 'node:child_process';
|
|
import dns from 'node:dns/promises';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import net from 'node:net';
|
|
import path from 'node:path';
|
|
|
|
import { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
|
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
|
import { getModuleDir } from '@/utils/runtime-paths.js';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const __dirname = getModuleDir(import.meta.url);
|
|
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
|
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
|
|
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
|
const ALLOW_PRIVATE_NETWORKS = process.env.CLOUDCLI_BROWSER_USE_ALLOW_PRIVATE_NETWORKS === '1';
|
|
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
|
|
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
|
|
|
|
type BrowserUseRuntime = 'cloud' | 'local';
|
|
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
|
|
|
type BrowserUseSession = {
|
|
id: string;
|
|
ownerId: string;
|
|
createdBy: 'user' | 'agent';
|
|
runtime: BrowserUseRuntime;
|
|
status: BrowserUseSessionStatus;
|
|
url: string | null;
|
|
title: string | null;
|
|
screenshotDataUrl: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
lastAction: string | null;
|
|
message: string | null;
|
|
agentAccessEnabled: boolean;
|
|
profileName: string | null;
|
|
viewport: {
|
|
width: number;
|
|
height: number;
|
|
} | null;
|
|
cursor: {
|
|
x: number;
|
|
y: number;
|
|
actor: 'agent' | 'user';
|
|
} | null;
|
|
};
|
|
|
|
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
|
|
|
|
type RuntimeHandle = {
|
|
browser?: any;
|
|
context?: any;
|
|
page?: any;
|
|
};
|
|
|
|
type BrowserUseOwner = {
|
|
id: string | number;
|
|
};
|
|
|
|
type BrowserUseSettings = {
|
|
enabled: boolean;
|
|
agentToolsEnabled: boolean;
|
|
};
|
|
|
|
type RuntimeReadiness = {
|
|
playwright: any | null;
|
|
playwrightInstalled: boolean;
|
|
chromiumInstalled: boolean;
|
|
chromiumExecutablePath: string | null;
|
|
installInProgress: boolean;
|
|
installMessage: string | null;
|
|
};
|
|
|
|
const sessions = new Map<string, BrowserUseSession>();
|
|
const handles = new Map<string, RuntimeHandle>();
|
|
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
|
let lastInstallMessage: string | null = null;
|
|
|
|
const DEFAULT_SETTINGS: BrowserUseSettings = {
|
|
enabled: false,
|
|
agentToolsEnabled: false,
|
|
};
|
|
const AGENT_OWNER_ID = 'agent';
|
|
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
|
|
const MCP_SERVER_NAME = 'cloudcli-browser-use';
|
|
const MCP_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'opencode'];
|
|
|
|
function getRuntime(): BrowserUseRuntime {
|
|
return IS_PLATFORM ? 'cloud' : 'local';
|
|
}
|
|
|
|
function readSettings(): BrowserUseSettings {
|
|
try {
|
|
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
|
|
if (!raw) {
|
|
return DEFAULT_SETTINGS;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
|
|
return {
|
|
enabled: parsed.enabled === true,
|
|
agentToolsEnabled: parsed.agentToolsEnabled === true,
|
|
};
|
|
} catch (error: any) {
|
|
console.warn('[Browser Use] Failed to read settings:', error?.message || error);
|
|
return DEFAULT_SETTINGS;
|
|
}
|
|
}
|
|
|
|
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
|
|
const normalized = {
|
|
enabled: settings.enabled === true,
|
|
agentToolsEnabled: settings.agentToolsEnabled === true,
|
|
};
|
|
|
|
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
|
|
return normalized;
|
|
}
|
|
|
|
function getOrCreateMcpToken(): string {
|
|
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const token = randomBytes(32).toString('hex');
|
|
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
|
|
return token;
|
|
}
|
|
|
|
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
|
|
if (!settings.enabled) {
|
|
return 'Browser Use is disabled in settings.';
|
|
}
|
|
|
|
if (!readiness.playwrightInstalled) {
|
|
return 'Install Playwright and Chromium to use browser sessions.';
|
|
}
|
|
|
|
if (!readiness.chromiumInstalled) {
|
|
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
|
|
}
|
|
|
|
return readiness.installMessage || 'Browser Use runtime is not ready.';
|
|
}
|
|
|
|
function getPlaywright(): any | null {
|
|
try {
|
|
return require('playwright');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getMcpCommand(): { command: string; args: string[] } {
|
|
const serverDir = path.resolve(__dirname, '..', '..');
|
|
const mcpScriptPath = path.join(serverDir, 'browser-use-mcp.js');
|
|
if (fs.existsSync(mcpScriptPath)) {
|
|
return {
|
|
command: process.execPath,
|
|
args: [mcpScriptPath],
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: 'cloudcli',
|
|
args: ['browser-use-mcp'],
|
|
};
|
|
}
|
|
|
|
function getMcpApiUrl(): string {
|
|
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
|
|
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
|
|
}
|
|
|
|
function normalizeProfileName(profileName?: string | null): string | null {
|
|
const normalized = String(profileName || '').trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
return normalized.slice(0, 80);
|
|
}
|
|
|
|
function getProfilePath(profileName: string): string {
|
|
const safeName = profileName
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80) || 'default';
|
|
return path.join(PROFILE_ROOT, safeName);
|
|
}
|
|
|
|
function getRuntimeReadiness(): RuntimeReadiness {
|
|
const playwright = getPlaywright();
|
|
const readiness: RuntimeReadiness = {
|
|
playwright,
|
|
playwrightInstalled: Boolean(playwright),
|
|
chromiumInstalled: false,
|
|
chromiumExecutablePath: null,
|
|
installInProgress: Boolean(installPromise),
|
|
installMessage: lastInstallMessage,
|
|
};
|
|
|
|
if (!playwright) {
|
|
return readiness;
|
|
}
|
|
|
|
try {
|
|
const executablePath = playwright.chromium.executablePath();
|
|
readiness.chromiumExecutablePath = executablePath;
|
|
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
|
|
} catch {
|
|
readiness.chromiumInstalled = false;
|
|
}
|
|
|
|
return readiness;
|
|
}
|
|
|
|
function runCommand(command: string, args: string[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const output: string[] = [];
|
|
|
|
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
|
child.stderr.on('data', (chunk) => output.push(String(chunk)));
|
|
child.on('error', reject);
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
function formatInstallError(error: unknown): string {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (message.includes('sudo') && message.includes('password')) {
|
|
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
|
|
}
|
|
return message || 'Failed to install Browser Use runtime.';
|
|
}
|
|
|
|
async function installRuntime(): Promise<{ success: boolean; message: string }> {
|
|
if (installPromise) {
|
|
return installPromise;
|
|
}
|
|
|
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
installPromise = (async () => {
|
|
try {
|
|
lastInstallMessage = 'Installing Playwright package...';
|
|
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
|
|
|
|
if (process.platform === 'linux') {
|
|
lastInstallMessage = 'Installing Chromium system dependencies...';
|
|
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install-deps', 'chromium']);
|
|
}
|
|
|
|
lastInstallMessage = 'Installing Chromium runtime...';
|
|
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
|
|
|
|
lastInstallMessage = 'Browser Use runtime installed.';
|
|
return { success: true, message: lastInstallMessage };
|
|
} catch (error) {
|
|
lastInstallMessage = formatInstallError(error);
|
|
return { success: false, message: lastInstallMessage };
|
|
}
|
|
})();
|
|
|
|
try {
|
|
return await installPromise;
|
|
} finally {
|
|
installPromise = null;
|
|
}
|
|
}
|
|
|
|
function getOwnerId(owner: BrowserUseOwner): string {
|
|
if (owner.id === undefined || owner.id === null || String(owner.id).trim() === '') {
|
|
throw new Error('Authenticated user is required.');
|
|
}
|
|
|
|
return String(owner.id);
|
|
}
|
|
|
|
function isPrivateIpv4(address: string): boolean {
|
|
const parts = address.split('.').map((part) => Number.parseInt(part, 10));
|
|
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
|
|
return true;
|
|
}
|
|
|
|
const [first, second] = parts;
|
|
return first === 0
|
|
|| first === 10
|
|
|| first === 127
|
|
|| (first === 169 && second === 254)
|
|
|| (first === 172 && second >= 16 && second <= 31)
|
|
|| (first === 192 && second === 168)
|
|
|| first >= 224;
|
|
}
|
|
|
|
function isPrivateIpv6(address: string): boolean {
|
|
const normalized = address.toLowerCase();
|
|
return normalized === '::1'
|
|
|| normalized === '::'
|
|
|| normalized.startsWith('fc')
|
|
|| normalized.startsWith('fd')
|
|
|| normalized.startsWith('fe80:')
|
|
|| normalized.startsWith('::ffff:127.')
|
|
|| normalized.startsWith('::ffff:10.')
|
|
|| normalized.startsWith('::ffff:192.168.')
|
|
|| /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized)
|
|
|| /^::ffff:169\.254\./.test(normalized);
|
|
}
|
|
|
|
export function isBlockedBrowserUseAddress(address: string): boolean {
|
|
const version = net.isIP(address);
|
|
if (version === 4) {
|
|
return isPrivateIpv4(address);
|
|
}
|
|
if (version === 6) {
|
|
return isPrivateIpv6(address);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function assertPublicHttpTarget(parsedUrl: URL): Promise<void> {
|
|
if (ALLOW_PRIVATE_NETWORKS) {
|
|
return;
|
|
}
|
|
|
|
const hostname = parsedUrl.hostname;
|
|
if (!hostname) {
|
|
throw new Error('URL hostname is required.');
|
|
}
|
|
|
|
if (net.isIP(hostname)) {
|
|
if (isBlockedBrowserUseAddress(hostname)) {
|
|
throw new Error('Browser Use cannot navigate to private or local network addresses.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const addresses = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
if (addresses.length === 0 || addresses.some((entry) => isBlockedBrowserUseAddress(entry.address))) {
|
|
throw new Error('Browser Use cannot navigate to private or local network addresses.');
|
|
}
|
|
}
|
|
|
|
async function normalizeUrl(rawUrl: string): Promise<string> {
|
|
const trimmed = rawUrl.trim();
|
|
if (!trimmed) {
|
|
throw new Error('URL is required.');
|
|
}
|
|
|
|
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
|
|
? trimmed
|
|
: `https://${trimmed}`;
|
|
const parsed = new URL(withProtocol);
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
throw new Error('Only http and https URLs are supported.');
|
|
}
|
|
|
|
await assertPublicHttpTarget(parsed);
|
|
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function assertAllowedBrowserRequest(rawUrl: string): Promise<void> {
|
|
const parsed = new URL(rawUrl);
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
return;
|
|
}
|
|
|
|
await assertPublicHttpTarget(parsed);
|
|
}
|
|
|
|
async function attachRequestGuard(page: any): Promise<void> {
|
|
await page.route('**/*', async (route: any) => {
|
|
try {
|
|
await assertAllowedBrowserRequest(route.request().url());
|
|
await route.continue();
|
|
} catch {
|
|
await route.abort('blockedbyclient');
|
|
}
|
|
});
|
|
}
|
|
|
|
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
|
|
const { ownerId: _ownerId, ...publicFields } = session;
|
|
return publicFields;
|
|
}
|
|
|
|
function ownerSessions(ownerId: string): BrowserUseSession[] {
|
|
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
|
}
|
|
|
|
function canAccessSession(ownerId: string, session: BrowserUseSession): boolean {
|
|
return session.ownerId === ownerId || session.ownerId === AGENT_OWNER_ID || session.agentAccessEnabled;
|
|
}
|
|
|
|
async function closeHandle(sessionId: string): Promise<void> {
|
|
const handle = handles.get(sessionId);
|
|
handles.delete(sessionId);
|
|
await handle?.context?.close?.().catch(() => undefined);
|
|
await handle?.browser?.close().catch(() => undefined);
|
|
}
|
|
|
|
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
|
await Promise.all([...sessions.values()].map(async (session) => {
|
|
if (session.status !== 'ready') {
|
|
return;
|
|
}
|
|
|
|
const updatedAt = Date.parse(session.updatedAt);
|
|
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
|
|
return;
|
|
}
|
|
|
|
await closeHandle(session.id);
|
|
session.status = 'stopped';
|
|
session.updatedAt = new Date(now).toISOString();
|
|
session.lastAction = 'expire';
|
|
session.message = 'Browser session expired after inactivity.';
|
|
}));
|
|
}
|
|
|
|
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
|
|
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
|
|
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
|
|
session.title = await page.title().catch(() => null);
|
|
session.url = page.url() || session.url;
|
|
session.viewport = page.viewportSize?.() || session.viewport;
|
|
session.updatedAt = new Date().toISOString();
|
|
}
|
|
|
|
async function getActionPoint(page: any, input: { selector?: string; text?: string; x?: number; y?: number }) {
|
|
if (typeof input.x === 'number' && typeof input.y === 'number') {
|
|
return { x: input.x, y: input.y };
|
|
}
|
|
|
|
const locator = input.selector
|
|
? page.locator(input.selector).first()
|
|
: input.text
|
|
? page.getByText(input.text, { exact: false }).first()
|
|
: null;
|
|
|
|
if (!locator) {
|
|
return null;
|
|
}
|
|
|
|
const box = await locator.boundingBox().catch(() => null);
|
|
if (!box) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
x: Math.round(box.x + box.width / 2),
|
|
y: Math.round(box.y + box.height / 2),
|
|
};
|
|
}
|
|
|
|
export const browserUseService = {
|
|
async getSettings() {
|
|
return readSettings();
|
|
},
|
|
|
|
async updateSettings(settings: Partial<BrowserUseSettings>) {
|
|
const current = readSettings();
|
|
const nextSettings = {
|
|
...current,
|
|
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
|
|
agentToolsEnabled: typeof settings.agentToolsEnabled === 'boolean'
|
|
? settings.agentToolsEnabled
|
|
: current.agentToolsEnabled,
|
|
};
|
|
if (!nextSettings.enabled) {
|
|
nextSettings.agentToolsEnabled = false;
|
|
}
|
|
|
|
const next = writeSettings(nextSettings);
|
|
if (next.agentToolsEnabled) {
|
|
await this.registerAgentMcp();
|
|
} else if (current.agentToolsEnabled) {
|
|
await this.unregisterAgentMcp();
|
|
}
|
|
return next;
|
|
},
|
|
|
|
async getStatus() {
|
|
const settings = readSettings();
|
|
const readiness = getRuntimeReadiness();
|
|
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
|
|
|
|
return {
|
|
enabled: settings.enabled,
|
|
runtime: getRuntime(),
|
|
available,
|
|
playwrightInstalled: readiness.playwrightInstalled,
|
|
chromiumInstalled: readiness.chromiumInstalled,
|
|
installInProgress: readiness.installInProgress,
|
|
sessionCount: sessions.size,
|
|
agentToolsEnabled: settings.agentToolsEnabled,
|
|
mcpRecommended: !settings.agentToolsEnabled,
|
|
message: available
|
|
? 'Browser Use runtime is available.'
|
|
: getSetupMessage(settings, readiness),
|
|
};
|
|
},
|
|
|
|
async registerAgentMcp() {
|
|
const { command, args } = getMcpCommand();
|
|
const results = await providerMcpService.addMcpServerToAllProviders({
|
|
name: MCP_SERVER_NAME,
|
|
scope: 'user',
|
|
transport: 'stdio',
|
|
command,
|
|
args,
|
|
env: {
|
|
CLOUDCLI_BROWSER_USE_MCP_TOKEN: getOrCreateMcpToken(),
|
|
CLOUDCLI_BROWSER_USE_API_URL: getMcpApiUrl(),
|
|
},
|
|
});
|
|
return { name: MCP_SERVER_NAME, command, args, results };
|
|
},
|
|
|
|
getMcpToken() {
|
|
return getOrCreateMcpToken();
|
|
},
|
|
|
|
async unregisterAgentMcp() {
|
|
const results = await Promise.all(MCP_PROVIDERS.map(async (provider) => {
|
|
try {
|
|
const result = await providerMcpService.removeProviderMcpServer(provider, {
|
|
name: MCP_SERVER_NAME,
|
|
scope: 'user',
|
|
});
|
|
return { provider, removed: result.removed };
|
|
} catch (error) {
|
|
return {
|
|
provider,
|
|
removed: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}));
|
|
return { name: MCP_SERVER_NAME, results };
|
|
},
|
|
|
|
async installRuntime() {
|
|
const result = await installRuntime();
|
|
return {
|
|
...result,
|
|
status: await this.getStatus(),
|
|
};
|
|
},
|
|
|
|
async listSessions(owner: BrowserUseOwner) {
|
|
const ownerId = getOwnerId(owner);
|
|
await expireStaleSessions();
|
|
return [...sessions.values()]
|
|
.filter((session) => canAccessSession(ownerId, session))
|
|
.map(publicSession);
|
|
},
|
|
|
|
async createSession(owner: BrowserUseOwner, options?: { createdBy?: 'user' | 'agent'; profileName?: string | null; agentAccessEnabled?: boolean }) {
|
|
const ownerId = getOwnerId(owner);
|
|
await expireStaleSessions();
|
|
const createdBy = options?.createdBy ?? 'user';
|
|
const profileName = normalizeProfileName(options?.profileName);
|
|
|
|
const now = new Date().toISOString();
|
|
const session: BrowserUseSession = {
|
|
id: randomUUID(),
|
|
ownerId,
|
|
createdBy,
|
|
runtime: getRuntime(),
|
|
status: 'unavailable',
|
|
url: null,
|
|
title: null,
|
|
screenshotDataUrl: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastAction: 'create',
|
|
message: null,
|
|
agentAccessEnabled: options?.agentAccessEnabled ?? createdBy === 'agent',
|
|
profileName,
|
|
viewport: { width: 1440, height: 900 },
|
|
cursor: null,
|
|
};
|
|
|
|
const activeOwnerSessions = ownerSessions(ownerId).filter((item) => item.status === 'ready');
|
|
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
|
|
throw new Error(`Browser Use is limited to ${MAX_SESSIONS_PER_OWNER} active sessions per user.`);
|
|
}
|
|
|
|
const settings = readSettings();
|
|
const readiness = getRuntimeReadiness();
|
|
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
|
|
session.message = getSetupMessage(settings, readiness);
|
|
sessions.set(session.id, session);
|
|
return publicSession(session);
|
|
}
|
|
|
|
let browser: any | undefined;
|
|
let context: any | undefined;
|
|
let page: any;
|
|
const launchOptions = {
|
|
headless: true,
|
|
args: ['--disable-dev-shm-usage'],
|
|
};
|
|
const contextOptions = {
|
|
viewport: { width: 1440, height: 900 },
|
|
serviceWorkers: 'block',
|
|
};
|
|
|
|
if (profileName) {
|
|
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
|
|
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
|
|
...launchOptions,
|
|
...contextOptions,
|
|
});
|
|
page = context.pages()[0] || await context.newPage();
|
|
} else {
|
|
browser = await readiness.playwright.chromium.launch(launchOptions);
|
|
context = await browser.newContext(contextOptions);
|
|
page = await context.newPage();
|
|
}
|
|
await attachRequestGuard(page);
|
|
session.status = 'ready';
|
|
session.message = 'Browser session is ready.';
|
|
sessions.set(session.id, session);
|
|
handles.set(session.id, { browser, context, page });
|
|
await captureSession(session, page);
|
|
return publicSession(session);
|
|
},
|
|
|
|
async grantAgentAccess(owner: BrowserUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || (session.ownerId !== ownerId && session.ownerId !== AGENT_OWNER_ID)) {
|
|
throw new Error('Browser session not found.');
|
|
}
|
|
session.agentAccessEnabled = true;
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'agent_access:grant';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async revokeAgentAccess(owner: BrowserUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || (session.ownerId !== ownerId && session.ownerId !== AGENT_OWNER_ID)) {
|
|
throw new Error('Browser session not found.');
|
|
}
|
|
session.agentAccessEnabled = false;
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'agent_access:revoke';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async listAgentSessions() {
|
|
const settings = readSettings();
|
|
if (!settings.enabled || !settings.agentToolsEnabled) {
|
|
return [];
|
|
}
|
|
await expireStaleSessions();
|
|
return [...sessions.values()]
|
|
.filter((session) => session.agentAccessEnabled || session.ownerId === AGENT_OWNER_ID)
|
|
.map(publicSession);
|
|
},
|
|
|
|
async createAgentSession(options?: { profileName?: string | null }) {
|
|
const settings = readSettings();
|
|
if (!settings.enabled || !settings.agentToolsEnabled) {
|
|
throw new Error('Browser Use agent tools are disabled.');
|
|
}
|
|
return this.createSession(
|
|
{ id: AGENT_OWNER_ID },
|
|
{ createdBy: 'agent', profileName: options?.profileName, agentAccessEnabled: true },
|
|
);
|
|
},
|
|
|
|
async getAgentSession(sessionId: string) {
|
|
const settings = readSettings();
|
|
if (!settings.enabled || !settings.agentToolsEnabled) {
|
|
throw new Error('Browser Use agent tools are disabled.');
|
|
}
|
|
const session = sessions.get(sessionId);
|
|
if (!session || (!session.agentAccessEnabled && session.ownerId !== AGENT_OWNER_ID)) {
|
|
throw new Error('Browser session is not shared with agents.');
|
|
}
|
|
return session;
|
|
},
|
|
|
|
async navigate(owner: BrowserUseOwner, sessionId: string, rawUrl: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
await expireStaleSessions();
|
|
|
|
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.');
|
|
}
|
|
|
|
const url = await normalizeUrl(rawUrl);
|
|
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
session.lastAction = `navigate:${url}`;
|
|
session.cursor = null;
|
|
await captureSession(session, handle.page);
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentNavigate(sessionId: string, rawUrl: string) {
|
|
await this.getAgentSession(sessionId);
|
|
return this.navigate({ id: AGENT_OWNER_ID }, sessionId, rawUrl).catch(async (error) => {
|
|
const session = await this.getAgentSession(sessionId);
|
|
if (session.ownerId !== AGENT_OWNER_ID) {
|
|
const url = await normalizeUrl(rawUrl);
|
|
const handle = handles.get(sessionId);
|
|
if (!handle?.page) {
|
|
throw new Error('Browser runtime handle is not available.');
|
|
}
|
|
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
session.lastAction = `navigate:${url}`;
|
|
await captureSession(session, handle.page);
|
|
return publicSession(session);
|
|
}
|
|
throw error;
|
|
});
|
|
},
|
|
|
|
async agentSnapshot(sessionId: string) {
|
|
const session = await this.getAgentSession(sessionId);
|
|
const handle = handles.get(sessionId);
|
|
if (!handle?.page) {
|
|
throw new Error('Browser runtime handle is not available.');
|
|
}
|
|
await captureSession(session, handle.page);
|
|
const text = await handle.page.locator('body').innerText({ timeout: 5_000 }).catch(() => '');
|
|
return {
|
|
session: publicSession(session),
|
|
text: text.slice(0, 30_000),
|
|
};
|
|
},
|
|
|
|
async agentClick(sessionId: string, input: { selector?: string; text?: string; x?: number; y?: number }) {
|
|
const session = await this.getAgentSession(sessionId);
|
|
const handle = handles.get(sessionId);
|
|
if (!handle?.page) {
|
|
throw new Error('Browser runtime handle is not available.');
|
|
}
|
|
const point = await getActionPoint(handle.page, input);
|
|
|
|
if (input.selector) {
|
|
await handle.page.locator(input.selector).first().click({ timeout: 10_000 });
|
|
} else if (input.text) {
|
|
await handle.page.getByText(input.text, { exact: false }).first().click({ timeout: 10_000 });
|
|
} else if (typeof input.x === 'number' && typeof input.y === 'number') {
|
|
await handle.page.mouse.click(input.x, input.y);
|
|
} else {
|
|
throw new Error('Provide selector, text, or x/y coordinates.');
|
|
}
|
|
|
|
session.lastAction = 'click';
|
|
session.cursor = point ? { ...point, actor: 'agent' } : null;
|
|
await captureSession(session, handle.page);
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentType(sessionId: string, input: { selector?: string; text: string; submit?: boolean }) {
|
|
const session = await this.getAgentSession(sessionId);
|
|
const handle = handles.get(sessionId);
|
|
if (!handle?.page) {
|
|
throw new Error('Browser runtime handle is not available.');
|
|
}
|
|
|
|
if (input.selector) {
|
|
await handle.page.locator(input.selector).first().fill(input.text, { timeout: 10_000 });
|
|
session.cursor = await getActionPoint(handle.page, input).then((point) => (
|
|
point ? { ...point, actor: 'agent' as const } : null
|
|
));
|
|
} else {
|
|
await handle.page.keyboard.type(input.text);
|
|
}
|
|
if (input.submit) {
|
|
await handle.page.keyboard.press('Enter');
|
|
}
|
|
|
|
session.lastAction = 'type';
|
|
await captureSession(session, handle.page);
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentFillForm(sessionId: string, fields: Array<{ selector: string; value: string }>) {
|
|
const session = await this.getAgentSession(sessionId);
|
|
const handle = handles.get(sessionId);
|
|
if (!handle?.page) {
|
|
throw new Error('Browser runtime handle is not available.');
|
|
}
|
|
for (const field of fields) {
|
|
await handle.page.locator(field.selector).first().fill(field.value, { timeout: 10_000 });
|
|
}
|
|
session.lastAction = 'fill_form';
|
|
if (fields[0]) {
|
|
session.cursor = await getActionPoint(handle.page, { selector: fields[0].selector }).then((point) => (
|
|
point ? { ...point, actor: 'agent' as const } : null
|
|
));
|
|
}
|
|
await captureSession(session, handle.page);
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentPressKey(sessionId: string, key: string) {
|
|
const session = await this.getAgentSession(sessionId);
|
|
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 agentSelectOption(sessionId: string, selector: string, values: string[]) {
|
|
const session = await this.getAgentSession(sessionId);
|
|
const handle = handles.get(sessionId);
|
|
if (!handle?.page) {
|
|
throw new Error('Browser runtime handle is not available.');
|
|
}
|
|
await handle.page.locator(selector).first().selectOption(values, { timeout: 10_000 });
|
|
session.lastAction = 'select_option';
|
|
session.cursor = await getActionPoint(handle.page, { selector }).then((point) => (
|
|
point ? { ...point, actor: 'agent' as const } : null
|
|
));
|
|
await captureSession(session, handle.page);
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentWaitFor(sessionId: string, input: { text?: string; url?: string; timeoutMs?: number }) {
|
|
const session = await this.getAgentSession(sessionId);
|
|
const handle = handles.get(sessionId);
|
|
if (!handle?.page) {
|
|
throw new Error('Browser runtime handle is not available.');
|
|
}
|
|
const timeout = Math.max(250, Math.min(input.timeoutMs || 5_000, 30_000));
|
|
if (input.text) {
|
|
await handle.page.getByText(input.text, { exact: false }).first().waitFor({ timeout });
|
|
} else if (input.url) {
|
|
await handle.page.waitForURL(input.url, { timeout });
|
|
} else {
|
|
await handle.page.waitForTimeout(timeout);
|
|
}
|
|
session.lastAction = 'wait_for';
|
|
await captureSession(session, handle.page);
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentTabs(sessionId: string, input: { action?: 'list' | 'new' | 'select' | 'close'; index?: number; url?: string }) {
|
|
const session = await this.getAgentSession(sessionId);
|
|
const handle = handles.get(sessionId);
|
|
if (!handle?.context || !handle?.page) {
|
|
throw new Error('Browser runtime handle is not available.');
|
|
}
|
|
const action = input.action || 'list';
|
|
if (action === 'new') {
|
|
const page = await handle.context.newPage();
|
|
handles.set(sessionId, { ...handle, page });
|
|
await attachRequestGuard(page);
|
|
if (input.url) {
|
|
await this.agentNavigate(sessionId, input.url);
|
|
}
|
|
} else if (action === 'select') {
|
|
const page = handle.context.pages()[input.index || 0];
|
|
if (!page) {
|
|
throw new Error('Tab not found.');
|
|
}
|
|
handles.set(sessionId, { ...handle, page });
|
|
} else if (action === 'close') {
|
|
const pages = handle.context.pages();
|
|
const page = pages[input.index ?? pages.indexOf(handle.page)];
|
|
if (!page) {
|
|
throw new Error('Tab not found.');
|
|
}
|
|
await page.close();
|
|
handles.set(sessionId, { ...handle, page: handle.context.pages()[0] || await handle.context.newPage() });
|
|
}
|
|
const updatedHandle = handles.get(sessionId);
|
|
await captureSession(session, updatedHandle?.page || handle.page);
|
|
return {
|
|
session: publicSession(session),
|
|
tabs: handle.context.pages().map((page: any, index: number) => ({
|
|
index,
|
|
url: page.url(),
|
|
active: page === (updatedHandle?.page || handle.page),
|
|
})),
|
|
};
|
|
},
|
|
|
|
async stopSession(owner: BrowserUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
return { stopped: false };
|
|
}
|
|
|
|
await closeHandle(sessionId);
|
|
|
|
session.status = 'stopped';
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'stop';
|
|
session.message = 'Browser session stopped. Create a new session to continue browsing.';
|
|
return { stopped: true, session: publicSession(session) };
|
|
},
|
|
|
|
async deleteSession(owner: BrowserUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
return { deleted: false };
|
|
}
|
|
|
|
await closeHandle(sessionId);
|
|
sessions.delete(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) {
|
|
await this.getAgentSession(sessionId);
|
|
return this.stopSession({ id: AGENT_OWNER_ID }, sessionId);
|
|
},
|
|
|
|
async stopAllSessions() {
|
|
await Promise.all([...sessions.keys()].map(async (sessionId) => {
|
|
await closeHandle(sessionId);
|
|
const session = sessions.get(sessionId);
|
|
if (session) {
|
|
session.status = 'stopped';
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'shutdown';
|
|
session.message = 'Browser session stopped during server shutdown.';
|
|
}
|
|
}));
|
|
},
|
|
};
|
|
|
|
process.once('beforeExit', () => {
|
|
void browserUseService.stopAllSessions();
|
|
});
|