Merge tag 'v0.15.0' into codex/sync-v0.15.0

v0.15.0

# Conflicts:
#	CHANGELOG.md
#	README.md
#	cli/Cargo.lock
#	cli/Cargo.toml
#	cli/src/commands.rs
#	cli/src/connection.rs
#	cli/src/flags.rs
#	cli/src/main.rs
#	docs/src/app/commands/page.mdx
#	docs/src/app/configuration/page.mdx
#	package.json
#	src/actions.ts
This commit is contained in:
leeguooooo
2026-02-27 10:49:55 +09:00
21 changed files with 2502 additions and 32 deletions
+81 -2
View File
@@ -21,6 +21,7 @@ import { writeFile, mkdir } from 'node:fs/promises';
import type { LaunchCommand, TraceEvent } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
import { safeHeaderMerge } from './state-utils.js';
import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
import {
getEncryptionKey,
isEncryptedPayload,
@@ -161,6 +162,7 @@ export class BrowserManager {
private contextHeaders: Record<string, string> | undefined = undefined;
private contextUserAgent: string | undefined = undefined;
private downloadPath: string | null = null;
private allowedDomains: string[] = [];
/**
* Set the persistent color scheme preference.
@@ -573,6 +575,61 @@ export class BrowserManager {
return parseRef(selector) !== null;
}
/**
* Install the domain filter on a context if an allowlist is configured.
* Should be called before any pages navigate on the context.
*/
private async ensureDomainFilter(context: BrowserContext): Promise<void> {
if (this.allowedDomains.length > 0) {
await installDomainFilter(context, this.allowedDomains);
}
}
/**
* After installing the domain filter, verify existing pages are on allowed
* domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have
* already navigated to disallowed domains. Navigate them to about:blank.
*/
private async sanitizeExistingPages(pages: Page[]): Promise<void> {
if (this.allowedDomains.length === 0) return;
for (const page of pages) {
const url = page.url();
if (!url || url === 'about:blank') continue;
try {
const hostname = new URL(url).hostname.toLowerCase();
if (!isDomainAllowed(hostname, this.allowedDomains)) {
await page.goto('about:blank');
}
} catch {
await page.goto('about:blank').catch(() => {});
}
}
}
/**
* Check if a URL is allowed by the domain allowlist.
* Throws if the URL's domain is blocked. No-op if no allowlist is set.
* Blocks non-http(s) schemes and unparseable URLs by default.
*/
checkDomainAllowed(url: string): void {
if (this.allowedDomains.length === 0) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`);
}
let hostname: string;
try {
hostname = new URL(url).hostname.toLowerCase();
} catch {
throw new Error(`Navigation blocked: unable to parse URL "${url}"`);
}
if (!isDomainAllowed(hostname, this.allowedDomains)) {
throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`);
}
}
/**
* Get locator - supports both refs and regular selectors
*/
@@ -645,6 +702,7 @@ export class BrowserManager {
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
await this.ensureDomainFilter(context);
} else {
return;
}
@@ -1278,6 +1336,8 @@ export class BrowserManager {
context.setDefaultTimeout(10000);
this.contexts.push(context);
this.setupContextTracking(context);
await this.ensureDomainFilter(context);
await this.sanitizeExistingPages([page]);
this.pages.push(page);
this.activePageIndex = 0;
this.setupPageTracking(page);
@@ -1421,10 +1481,12 @@ export class BrowserManager {
this.browser = browser;
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
await this.ensureDomainFilter(context);
await this.sanitizeExistingPages([page]);
this.pages.push(page);
this.activePageIndex = 0;
this.setupPageTracking(page);
this.setupContextTracking(context);
} catch (error) {
await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
console.error('Failed to close Kernel session during cleanup:', sessionError);
@@ -1497,10 +1559,12 @@ export class BrowserManager {
this.browser = browser;
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
await this.ensureDomainFilter(context);
await this.sanitizeExistingPages([page]);
this.pages.push(page);
this.activePageIndex = 0;
this.setupPageTracking(page);
this.setupContextTracking(context);
} catch (error) {
await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
console.error('Failed to close Browser Use session during cleanup:', sessionError);
@@ -1572,6 +1636,15 @@ export class BrowserManager {
this.downloadPath = options.downloadPath;
}
if (options.allowedDomains && options.allowedDomains.length > 0) {
this.allowedDomains = options.allowedDomains.map((d: string) => d.toLowerCase());
} else {
const envDomains = process.env.AGENT_BROWSER_ALLOWED_DOMAINS;
if (envDomains) {
this.allowedDomains = parseDomainList(envDomains);
}
}
if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
const warning =
"--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
@@ -1832,8 +1905,10 @@ export class BrowserManager {
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
await this.ensureDomainFilter(context);
const page = context.pages()[0] ?? (await context.newPage());
await this.sanitizeExistingPages([page]);
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
if (!this.pages.includes(page)) {
this.pages.push(page);
@@ -1930,8 +2005,11 @@ export class BrowserManager {
context.setDefaultTimeout(10000);
this.contexts.push(context);
this.setupContextTracking(context);
await this.ensureDomainFilter(context);
}
await this.sanitizeExistingPages(allPages);
for (const page of allPages) {
this.pages.push(page);
this.setupPageTracking(page);
@@ -2200,6 +2278,7 @@ export class BrowserManager {
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
await this.ensureDomainFilter(context);
const page = await context.newPage();
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)