mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-25 20:25:51 +08:00
fix(browser-use): harden browser settings state
This commit is contained in:
@@ -219,6 +219,54 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRuntimeProcessAlive(child: ReturnType<typeof spawn>): boolean {
|
||||
return child.exitCode === null && child.signalCode === null && !child.killed;
|
||||
}
|
||||
|
||||
function assertRuntimeProcessesAlive(processes: Array<ReturnType<typeof spawn>>, label: string) {
|
||||
const exited = processes.find((child) => !isRuntimeProcessAlive(child));
|
||||
if (exited) {
|
||||
throw new Error(`${label} exited before the Browser viewer runtime was ready.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
let settled = false;
|
||||
const finish = (listening: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(listening);
|
||||
};
|
||||
socket.setTimeout(250);
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('timeout', () => finish(false));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRuntimePort(
|
||||
port: number,
|
||||
label: string,
|
||||
processes: Array<ReturnType<typeof spawn>>,
|
||||
timeoutMs = 5_000,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
assertRuntimeProcessesAlive(processes, label);
|
||||
if (await isPortListening(port)) {
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
assertRuntimeProcessesAlive(processes, label);
|
||||
throw new Error(`${label} did not start listening on 127.0.0.1:${port}.`);
|
||||
}
|
||||
|
||||
function killRuntimeProcesses(processes?: Array<ReturnType<typeof spawn>>) {
|
||||
processes?.forEach((child) => child.kill('SIGTERM'));
|
||||
}
|
||||
@@ -272,6 +320,7 @@ async function startVisibleRuntime(): Promise<NonNullable<RuntimeHandle['viewer'
|
||||
'tcp',
|
||||
]));
|
||||
await delay(700);
|
||||
assertRuntimeProcessesAlive(processes, 'Xvfb');
|
||||
|
||||
if (!fs.existsSync(X11VNC_BIN)) {
|
||||
throw new Error(`x11vnc is missing at ${X11VNC_BIN}.`);
|
||||
@@ -291,7 +340,7 @@ async function startVisibleRuntime(): Promise<NonNullable<RuntimeHandle['viewer'
|
||||
LD_LIBRARY_PATH: `${X11VNC_LIB_DIR}:${X11VNC_EXTRA_LIB_DIR}:${process.env.LD_LIBRARY_PATH || ''}`,
|
||||
},
|
||||
}));
|
||||
await delay(500);
|
||||
await waitForRuntimePort(vncPort, 'x11vnc', processes);
|
||||
|
||||
if (!fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'))) {
|
||||
throw new Error(`noVNC is missing at ${NOVNC_ROOT}.`);
|
||||
@@ -302,7 +351,7 @@ async function startVisibleRuntime(): Promise<NonNullable<RuntimeHandle['viewer'
|
||||
`127.0.0.1:${websockifyPort}`,
|
||||
`127.0.0.1:${vncPort}`,
|
||||
]));
|
||||
await delay(500);
|
||||
await waitForRuntimePort(websockifyPort, 'websockify', processes);
|
||||
|
||||
return {
|
||||
display,
|
||||
@@ -455,7 +504,10 @@ function validateViewerTokenForSession(sessionId: string, token: string | null |
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const viewer = getSessionViewer(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
const viewer = session?.ownerId === AGENT_OWNER_ID && session.status === 'ready'
|
||||
? handles.get(sessionId)?.viewer || null
|
||||
: null;
|
||||
const stored = viewerTokens.get(sessionId);
|
||||
if (!viewer || !stored || stored.token !== token || stored.expiresAt < Date.now()) {
|
||||
if (stored?.expiresAt && stored.expiresAt < Date.now()) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -9,6 +10,7 @@ import type { BrowserUseBackend, BrowserUseSettings } from './browser-use.types.
|
||||
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
|
||||
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
|
||||
const MAX_PROFILE_NAME_LENGTH = 80;
|
||||
|
||||
export const DEFAULT_BROWSER_USE_SETTINGS: BrowserUseSettings = {
|
||||
enabled: false,
|
||||
@@ -26,12 +28,18 @@ export function normalizeBrowserBackend(value: unknown): BrowserUseBackend {
|
||||
}
|
||||
|
||||
export function normalizeProfileName(profileName?: string | null): string | null {
|
||||
const normalized = String(profileName || '').trim();
|
||||
const normalized = String(profileName || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_PROFILE_NAME_LENGTH)
|
||||
.replace(/^-+|-+$/g, '');
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.slice(0, 80);
|
||||
return /[a-z0-9]/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
export function normalizeDefaultProfileName(profileName?: string | null): string {
|
||||
@@ -40,19 +48,43 @@ export function normalizeDefaultProfileName(profileName?: string | null): string
|
||||
|
||||
export function resolveSessionProfileName(settings: BrowserUseSettings, profileName?: string | null): string | null {
|
||||
const requestedProfileName = normalizeProfileName(profileName);
|
||||
if (String(profileName || '').trim() && !requestedProfileName) {
|
||||
throw new Error('Browser profile name must include at least one letter or number.');
|
||||
}
|
||||
if (requestedProfileName) {
|
||||
validateRequestedProfileName(profileName, requestedProfileName);
|
||||
return requestedProfileName;
|
||||
}
|
||||
return settings.persistSessions ? normalizeDefaultProfileName(settings.defaultProfileName) : null;
|
||||
}
|
||||
|
||||
export 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);
|
||||
return path.join(PROFILE_ROOT, normalizeDefaultProfileName(profileName));
|
||||
}
|
||||
|
||||
function validateRequestedProfileName(profileName: string | null | undefined, normalizedProfileName: string): void {
|
||||
const requestedProfileName = String(profileName || '').trim();
|
||||
const existingProfileName = findExistingProfileName(normalizedProfileName);
|
||||
if (
|
||||
existingProfileName
|
||||
&& (requestedProfileName !== normalizedProfileName || existingProfileName !== normalizedProfileName)
|
||||
) {
|
||||
throw new Error(`Browser profile "${requestedProfileName}" resolves to existing profile "${existingProfileName}". Use "${normalizedProfileName}" instead.`);
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingProfileName(normalizedProfileName: string): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(PROFILE_ROOT)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(PROFILE_ROOT, { withFileTypes: true });
|
||||
const match = entries.find((entry) => entry.isDirectory() && normalizeProfileName(entry.name) === normalizedProfileName);
|
||||
return match?.name || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useVisibleCamoufoxBackend(settings: BrowserUseSettings): boolean {
|
||||
|
||||
@@ -5,10 +5,15 @@ import type { RuntimeHandle } from './browser-use.types.js';
|
||||
type BrowserUseViewer = NonNullable<RuntimeHandle['viewer']>;
|
||||
|
||||
export const VIEWER_COOKIE_NAME = 'browser_use_viewer_token';
|
||||
export const VIEWER_TOKEN_TTL_MS = Number.parseInt(
|
||||
process.env.CLOUDCLI_BROWSER_USE_VIEWER_TOKEN_TTL_MS || String(30 * 60 * 1000),
|
||||
const DEFAULT_VIEWER_TOKEN_TTL_MS = 30 * 60 * 1000;
|
||||
const parsedViewerTokenTtlMs = Number.parseInt(
|
||||
process.env.CLOUDCLI_BROWSER_USE_VIEWER_TOKEN_TTL_MS || String(DEFAULT_VIEWER_TOKEN_TTL_MS),
|
||||
10,
|
||||
);
|
||||
export const VIEWER_TOKEN_TTL_MS =
|
||||
Number.isFinite(parsedViewerTokenTtlMs) && parsedViewerTokenTtlMs > 0
|
||||
? parsedViewerTokenTtlMs
|
||||
: DEFAULT_VIEWER_TOKEN_TTL_MS;
|
||||
|
||||
export function getViewerUrl(sessionId: string, viewerToken?: string): string {
|
||||
const basePath = `/api/browser-use/sessions/${encodeURIComponent(sessionId)}/viewer`;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getProfilePath,
|
||||
normalizeDefaultProfileName,
|
||||
normalizeProfileName,
|
||||
PROFILE_ROOT,
|
||||
resolveSessionProfileName,
|
||||
} from '@/modules/browser-use/browser-use.settings.js';
|
||||
|
||||
test('browser profile names are canonicalized before storage and path resolution', () => {
|
||||
assert.equal(normalizeProfileName(' Work Profile!! '), 'work-profile');
|
||||
assert.equal(normalizeDefaultProfileName(' Work Profile!! '), 'work-profile');
|
||||
assert.equal(
|
||||
getProfilePath(' Work Profile!! '),
|
||||
`${PROFILE_ROOT}/work-profile`,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSessionProfileName({
|
||||
enabled: true,
|
||||
persistSessions: true,
|
||||
defaultProfileName: ' Work Profile!! ',
|
||||
browserBackend: 'playwright',
|
||||
}),
|
||||
'work-profile',
|
||||
);
|
||||
});
|
||||
|
||||
test('browser profile aliases are rejected when the normalized profile already exists', () => {
|
||||
const profileName = `alias-test-${Date.now()}`;
|
||||
fs.mkdirSync(getProfilePath(profileName), { recursive: true });
|
||||
|
||||
try {
|
||||
assert.throws(
|
||||
() => resolveSessionProfileName({
|
||||
enabled: true,
|
||||
persistSessions: false,
|
||||
defaultProfileName: 'default',
|
||||
browserBackend: 'playwright',
|
||||
}, profileName.toUpperCase()),
|
||||
/resolves to existing profile/,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSessionProfileName({
|
||||
enabled: true,
|
||||
persistSessions: false,
|
||||
defaultProfileName: 'default',
|
||||
browserBackend: 'playwright',
|
||||
}, profileName),
|
||||
profileName,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(getProfilePath(profileName), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user