mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-28 07:15:35 +08:00
feat(browser-use): improve mobile monitoring ux
This commit is contained in:
@@ -1,10 +1,8 @@
|
|||||||
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';
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -299,71 +296,7 @@ async function installRuntime(): Promise<{ success: boolean; message: string }>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateIpv4(address: string): boolean {
|
function normalizeUrl(rawUrl: string): string {
|
||||||
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.');
|
||||||
@@ -377,31 +310,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> {
|
|
||||||
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;
|
||||||
@@ -630,7 +541,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);
|
||||||
@@ -680,7 +590,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;
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
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', () => {
|
|
||||||
assert.equal(isBlockedBrowserUseAddress('127.0.0.1'), true);
|
|
||||||
assert.equal(isBlockedBrowserUseAddress('10.0.0.12'), true);
|
|
||||||
assert.equal(isBlockedBrowserUseAddress('172.16.4.8'), true);
|
|
||||||
assert.equal(isBlockedBrowserUseAddress('192.168.1.4'), true);
|
|
||||||
assert.equal(isBlockedBrowserUseAddress('169.254.169.254'), true);
|
|
||||||
assert.equal(isBlockedBrowserUseAddress('::1'), true);
|
|
||||||
assert.equal(isBlockedBrowserUseAddress('8.8.8.8'), false);
|
|
||||||
assert.equal(isBlockedBrowserUseAddress('2001:4860:4860::8888'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser use monitor list starts empty without agent sessions', async () => {
|
test('browser use monitor list starts empty without agent sessions', async () => {
|
||||||
const sessions = await browserUseService.listSessions();
|
const sessions = await browserUseService.listSessions();
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
Bot,
|
Bot,
|
||||||
Clock3,
|
Clock3,
|
||||||
Download,
|
Download,
|
||||||
Expand,
|
Expand,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Globe2,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -99,19 +97,25 @@ function formatAction(action: string | null): string {
|
|||||||
|
|
||||||
function getStatusTone(status: BrowserUseSession['status']): string {
|
function getStatusTone(status: BrowserUseSession['status']): string {
|
||||||
if (status === 'ready') {
|
if (status === 'ready') {
|
||||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
|
return 'border-primary/30 bg-primary/5 text-foreground';
|
||||||
}
|
}
|
||||||
if (status === 'stopped') {
|
if (status === 'stopped') {
|
||||||
return 'border-border bg-muted text-muted-foreground';
|
return 'border-border bg-muted text-muted-foreground';
|
||||||
}
|
}
|
||||||
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
|
return 'border-border bg-background text-muted-foreground';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuntimeTone(status: BrowserUseStatus | null, installing: boolean): string {
|
function getRuntimeTone(status: BrowserUseStatus | null, installing: boolean): string {
|
||||||
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
|
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
|
||||||
if (status.available) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
|
if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
|
||||||
if (status.installInProgress || installing) return 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300';
|
if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
|
||||||
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
|
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 = [
|
const PROMPTS = [
|
||||||
@@ -233,10 +237,13 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">{session.title || getDomain(session.url)}</div>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="mt-1 truncate text-xs text-muted-foreground">{getDomain(session.url)}</div>
|
<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>
|
</div>
|
||||||
<Badge variant="outline" className={cn('shrink-0 text-[10px]', getStatusTone(session.status))}>
|
<Badge variant="outline" className="shrink-0 border-border bg-background text-[10px] text-muted-foreground">
|
||||||
{session.status}
|
{session.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +276,7 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{needsBrowserBinaries && (
|
{needsBrowserBinaries && (
|
||||||
<div className="mt-4 rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3">
|
||||||
<div className="text-sm font-medium text-foreground">Runtime setup required</div>
|
<div className="text-sm font-medium text-foreground">Runtime setup required</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{status?.message}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{status?.message}</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -378,29 +385,41 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px]">
|
{sessions.length > 0 && (
|
||||||
|
<div className="border-b border-border/60 bg-muted/20 px-3 py-2 lg:hidden">
|
||||||
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedSessionId(session.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-[180px] items-center gap-2 rounded-md border px-2.5 py-2 text-left',
|
||||||
|
selectedSession?.id === session.id
|
||||||
|
? 'border-primary/40 bg-primary/5'
|
||||||
|
: 'border-border bg-background',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||||
|
{session.title || getDomain(session.url)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">
|
<main className="flex min-h-0 flex-col overflow-hidden">
|
||||||
<div className="grid grid-cols-3 border-b border-border/60 bg-muted/20">
|
<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="border-r border-border/60 px-4 py-3">
|
<div className="min-w-0 truncate">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
{activeSessions.length} active
|
||||||
<Activity className="h-3.5 w-3.5" />
|
<span className="px-1.5">/</span>
|
||||||
Active
|
{sessions.length} total
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xl font-semibold text-foreground">{activeSessions.length}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border-r border-border/60 px-4 py-3">
|
<div className="min-w-0 truncate">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
Updated {formatRelativeTime(selectedSession?.updatedAt || null)}
|
||||||
<Globe2 className="h-3.5 w-3.5" />
|
|
||||||
Current
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 truncate text-sm font-medium text-foreground">{getDomain(selectedSession?.url || null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Clock3 className="h-3.5 w-3.5" />
|
|
||||||
Updated
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-sm font-medium text-foreground">{formatRelativeTime(selectedSession?.updatedAt || null)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -425,9 +444,14 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
<div className="hidden text-xs text-muted-foreground md:block">
|
<div className="hidden text-xs text-muted-foreground md:block">
|
||||||
{formatAction(selectedSession?.lastAction || null)}
|
{formatAction(selectedSession?.lastAction || null)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl}>
|
<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" />
|
<Expand className="h-4 w-4" />
|
||||||
Full Screen
|
</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{renderBrowserSurface()}
|
{renderBrowserSurface()}
|
||||||
@@ -436,7 +460,7 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="flex min-h-0 flex-col border-t border-border/60 bg-background lg:border-l lg:border-t-0">
|
<aside className="hidden min-h-0 flex-col border-l border-border/60 bg-background lg:flex">
|
||||||
<div className="border-b border-border/60 px-4 py-3">
|
<div className="border-b border-border/60 px-4 py-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user