fix(cdp): 自动拉起 9333 专用浏览器

This commit is contained in:
leeguooooo
2026-03-09 17:45:13 +09:00
parent b3ed4b63be
commit 8eee9310a4
11 changed files with 249 additions and 100 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ flowchart TD
- Project policy forbids:
- `--profile` / `AGENT_BROWSER_PROFILE`
- `--channel` / `AGENT_BROWSER_CHANNEL`
- Default CLI policy auto-attaches an existing browser: try CDP `localhost:9333` first, then auto-discovery unless explicit connection options are provided.
- Default CLI policy uses a dedicated automation browser on CDP `localhost:9333`. If `:9333` is unavailable, agent-browser auto-starts Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile`.
## Principle 2: Multi-Layer Fingerprint Hardening
+4 -25
View File
@@ -623,8 +623,9 @@ fn main() {
}
// Project policy: when no explicit connection mode is provided,
// commands should attach to an existing browser.
// Try CDP :9333 first, then fall back to auto-connect discovery.
// commands should use the dedicated automation browser on localhost:9333.
// If 9333 is unavailable, the native daemon auto-starts a managed Chrome
// instance with a non-default profile and retries the CDP connection.
if can_try_default_cdp {
let mut launch_cmd = json!({
"id": gen_id(),
@@ -645,31 +646,9 @@ fn main() {
if let Ok(resp) = send_command(launch_cmd, &flags.session) {
attached_to_existing_browser = resp.success;
}
if !attached_to_existing_browser {
let mut auto_connect_cmd = json!({
"id": gen_id(),
"action": "launch",
"autoConnect": true
});
if let Some(ref cs) = flags.color_scheme {
auto_connect_cmd["colorScheme"] = json!(cs);
}
if let Some(ref tg) = flags.tab_group {
auto_connect_cmd["tabGroup"] = json!(tg);
}
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
auto_connect_cmd["tabGroupPluginId"] = json!(plugin_id);
}
if let Ok(resp) = send_command(auto_connect_cmd, &flags.session) {
attached_to_existing_browser = resp.success;
}
}
}
if can_try_default_cdp && !attached_to_existing_browser {
let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed. Start Chrome with remote debugging (for example, --remote-debugging-port=9333), or pass --cdp <port|url>.";
let msg = "Project policy requires using the dedicated automation browser on localhost:9333. Could not connect to or auto-start the managed Chrome profile. Start Chrome with --remote-debugging-port=9333 and a non-default --user-data-dir, or pass --cdp / --auto-connect explicitly.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
+19 -2
View File
@@ -4,7 +4,7 @@ use tokio::sync::broadcast;
use super::auth;
use super::browser::{BrowserManager, WaitUntil};
use super::cdp::chrome::LaunchOptions;
use super::cdp::chrome::{LaunchOptions, MANAGED_CDP_PORT};
use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent,
CreateTargetResult, ExceptionThrownEvent, TargetCreatedEvent, TargetDestroyedEvent,
@@ -790,6 +790,7 @@ fn launch_options_from_env() -> LaunchOptions {
.unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
remote_debugging_port: None,
}
}
@@ -898,7 +899,22 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
}
if let Some(port) = cdp_port {
state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
let headed = !headless;
let port_u16 = u16::try_from(port).map_err(|_| format!("Invalid CDP port: {}", port))?;
let browser = match BrowserManager::connect_cdp(&port.to_string()).await {
Ok(browser) => browser,
Err(err) if port_u16 == MANAGED_CDP_PORT => {
if std::env::var("AGENT_BROWSER_DEBUG").as_deref() == Ok("1") {
eprintln!(
"[DEBUG] Preferred CDP port {} unavailable ({}), launching managed Chrome profile",
MANAGED_CDP_PORT, err
);
}
BrowserManager::launch_managed_cdp(executable_path.clone(), headed).await?
}
Err(err) => return Err(err),
};
state.browser = Some(browser);
state.subscribe_to_browser_events();
return Ok(json!({ "launched": true }));
}
@@ -990,6 +1006,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.get("downloadPath")
.and_then(|v| v.as_str())
.map(String::from),
remote_debugging_port: None,
};
if let Some(ref domains) = cmd
+30 -2
View File
@@ -5,7 +5,8 @@ use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use super::cdp::chrome::{
auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions,
auto_connect_cdp, discover_cdp_url, launch_chrome, launch_managed_chrome, ChromeProcess,
LaunchOptions,
};
use super::cdp::client::CdpClient;
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
@@ -144,6 +145,7 @@ pub enum BrowserProcess {
pub struct BrowserManager {
pub client: CdpClient,
browser_process: Option<BrowserProcess>,
cdp_connection: bool,
pages: Vec<PageInfo>,
active_page_index: usize,
default_timeout_ms: u64,
@@ -202,6 +204,7 @@ impl BrowserManager {
let mut manager = Self {
client,
browser_process: Some(process),
cdp_connection: false,
pages: Vec::new(),
active_page_index: 0,
default_timeout_ms: 25_000,
@@ -264,6 +267,31 @@ impl BrowserManager {
let mut manager = Self {
client,
browser_process: None,
cdp_connection: true,
pages: Vec::new(),
active_page_index: 0,
default_timeout_ms: 10_000,
};
manager.discover_and_attach_targets().await?;
Ok(manager)
}
pub async fn launch_managed_cdp(
executable_path: Option<String>,
headed: bool,
) -> Result<Self, String> {
let process =
tokio::task::spawn_blocking(move || launch_managed_chrome(executable_path, headed))
.await
.map_err(|e| format!("Managed Chrome launch task failed: {}", e))??;
let ws_url = process.ws_url.clone();
let client = CdpClient::connect(&ws_url).await?;
let mut manager = Self {
client,
browser_process: Some(BrowserProcess::Chrome(process)),
cdp_connection: true,
pages: Vec::new(),
active_page_index: 0,
default_timeout_ms: 10_000,
@@ -643,7 +671,7 @@ impl BrowserManager {
/// Returns true if this manager was connected via CDP (as opposed to local launch).
pub fn is_cdp_connection(&self) -> bool {
self.browser_process.is_none()
self.cdp_connection
}
/// Ensures the browser has at least one page. If `pages` is empty, creates a new
+46 -1
View File
@@ -73,6 +73,7 @@ pub struct LaunchOptions {
pub ignore_https_errors: bool,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub remote_debugging_port: Option<u16>,
}
impl Default for LaunchOptions {
@@ -91,6 +92,7 @@ impl Default for LaunchOptions {
ignore_https_errors: false,
color_scheme: None,
download_path: None,
remote_debugging_port: None,
}
}
}
@@ -101,8 +103,10 @@ struct ChromeArgs {
}
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
let remote_debugging_port = options.remote_debugging_port.unwrap_or(0);
let mut args = vec![
"--remote-debugging-port=0".to_string(),
format!("--remote-debugging-port={}", remote_debugging_port),
"--remote-debugging-address=127.0.0.1".to_string(),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--disable-background-networking".to_string(),
@@ -186,6 +190,47 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
})
}
pub const MANAGED_CDP_PORT: u16 = 9333;
pub fn managed_cdp_profile_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| std::env::temp_dir())
.join(".agent-browser")
.join("chrome-bot-profile")
}
fn cleanup_managed_profile_locks(profile_dir: &Path) {
let _ = std::fs::remove_file(profile_dir.join("DevToolsActivePort"));
if let Ok(entries) = std::fs::read_dir(profile_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with("Singleton") {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
pub fn launch_managed_chrome(
executable_path: Option<String>,
headed: bool,
) -> Result<ChromeProcess, String> {
let profile_dir = managed_cdp_profile_dir();
std::fs::create_dir_all(&profile_dir)
.map_err(|e| format!("Failed to create managed Chrome profile dir: {}", e))?;
cleanup_managed_profile_locks(&profile_dir);
let options = LaunchOptions {
headless: !headed,
executable_path,
profile: Some(profile_dir.to_string_lossy().to_string()),
remote_debugging_port: Some(MANAGED_CDP_PORT),
..Default::default()
};
launch_chrome(&options)
}
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let chrome_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
+3 -2
View File
@@ -2461,7 +2461,7 @@ Options:
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED=1/true)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--auto-connect Auto-discover and connect to running Chrome
Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
Explicit existing-browser mode; may trigger Chrome permission prompts
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
--tab-group <name> Base title for agent tab groups (CDP plugin mode; silent no-op if plugin unavailable)
@@ -2490,7 +2490,8 @@ Policy:
--profile / AGENT_BROWSER_PROFILE are forbidden
--channel / AGENT_BROWSER_CHANNEL are forbidden
Daemon auto-shuts down after 10 minutes of inactivity unless --resident is set
Auto-attach existing browser (prefer CDP localhost:9333, then auto-discovery), or pass --cdp explicitly
Default mode uses localhost:9333. If 9333 is unavailable, agent-browser auto-starts a dedicated Chrome profile at ~/.agent-browser/chrome-bot-profile
Use --auto-connect only when you explicitly want to attach to an existing manual browser session
Configuration:
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
+3 -1
View File
@@ -6,7 +6,7 @@ export const metadata = pageMetadata('cdp-mode');
Connect to an existing browser via Chrome DevTools Protocol:
Default behavior in this fork: when `--cdp` is omitted, agent-browser auto-attaches to an existing browser by trying `localhost:9333` first, then auto-discovery. If both fail, the command exits (no managed local-launch fallback).
Default behavior in this fork: when `--cdp` is omitted, agent-browser targets the managed automation browser on `localhost:9333`. If `:9333` is unavailable, it auto-starts Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile` and retries the CDP connection.
Project policy:
@@ -67,6 +67,8 @@ This is useful when:
- You want a zero-configuration connection to your existing browser
- You don't want to track which port Chrome is using
Use this mode only when you intentionally want to attach to an existing manual browser session. Recent Chrome builds may display a permission prompt before allowing remote debugging access to that session.
## Color scheme
Playwright overrides the browser's color scheme to `light` by default when connecting via CDP. Use `--color-scheme` to set a persistent preference:
+1 -1
View File
@@ -6,7 +6,7 @@ export const metadata = pageMetadata('configuration');
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
In this fork, default launch behavior auto-attaches to an existing browser by trying `localhost:9333` (CDP) first, then auto-discovery. If both fail, commands exit instead of launching a managed browser.
In this fork, default launch behavior uses a dedicated automation browser on `localhost:9333` (CDP). If `:9333` is unavailable, agent-browser auto-starts Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile` and retries the connection.
## Config File Locations
+5 -5
View File
@@ -220,14 +220,14 @@ Default-session commands intentionally reap all non-default daemon sessions (`pa
### Connect to Existing Chrome
By default in this fork, commands without `--cdp` auto-attach to your existing browser with this order:
By default in this fork, commands without `--cdp` use a dedicated automation browser with this order:
1. Try CDP at `localhost:9333`
2. If unavailable, fall back to `--auto-connect`-style discovery
3. If both fail, exit with guidance (no automatic managed local browser launch on this path)
2. If unavailable, auto-start Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile`
3. If managed `:9333` startup fails, exit with guidance
```bash
# Auto-discover running Chrome with remote debugging enabled
# Explicitly attach to an existing manual Chrome session
agent-browser --auto-connect open https://example.com
agent-browser --auto-connect snapshot
@@ -331,7 +331,7 @@ agent-browser screenshot output.png
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
- Use existing browser sessions (default attach path: CDP `localhost:9333` then auto-discovery) or pass `--cdp` explicitly
- Default mode uses the managed CDP browser on `localhost:9333`; use `--auto-connect` only for explicit existing-browser attachment
### Stealth Mode (Always On)
+127 -2
View File
@@ -14,9 +14,10 @@ import {
type CDPSession,
type Video,
} from 'playwright-core';
import { spawn, spawnSync } from 'node:child_process';
import path from 'node:path';
import os from 'node:os';
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, readdirSync, rmSync, readFileSync, statSync } from 'node:fs';
import { writeFile, mkdir } from 'node:fs/promises';
import type {
DoctorCheck,
@@ -134,6 +135,8 @@ interface StealthContextDefaults {
}
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
const MANAGED_CDP_PORT = 9333;
const MANAGED_CDP_START_TIMEOUT_MS = 20_000;
const DEFAULT_TAB_GROUP_NAME = 'Agent Browser Stealth';
const DEFAULT_TAB_GROUP_PLUGIN_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const TAB_GROUP_REQUEST_MESSAGE_TYPE = 'AB_TAB_GROUP_REQUEST';
@@ -149,6 +152,65 @@ interface TabGroupIntent {
allowedDomains: string[];
}
function getManagedCdpProfileDir(): string {
return path.join(os.homedir(), '.agent-browser', 'chrome-bot-profile');
}
function cleanupManagedCdpProfileLocks(profileDir: string): void {
rmSync(path.join(profileDir, 'DevToolsActivePort'), { force: true });
try {
for (const entry of readdirSync(profileDir)) {
if (entry.startsWith('Singleton')) {
rmSync(path.join(profileDir, entry), { force: true, recursive: true });
}
}
} catch {
// Best-effort cleanup for stale lock files.
}
}
function findManagedChromeExecutable(): string {
const configured = process.env.AGENT_BROWSER_EXECUTABLE_PATH;
if (configured && existsSync(configured)) {
return configured;
}
const platform = os.platform();
const candidates =
platform === 'darwin'
? [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
]
: platform === 'win32'
? [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
]
: [];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
if (platform !== 'win32') {
for (const name of ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium']) {
const result = spawnSync('which', [name], { encoding: 'utf8' });
if (result.status === 0) {
const resolved = result.stdout.trim();
if (resolved.length > 0) {
return resolved;
}
}
}
}
throw new Error('Chrome not found. Install Chrome or set AGENT_BROWSER_EXECUTABLE_PATH.');
}
/**
* Manages the Playwright browser lifecycle with multiple tabs/windows
*/
@@ -189,6 +251,61 @@ export class BrowserManager {
private tabGroupCapabilityBySession: Map<string, TabGroupPluginAvailability> = new Map();
private tabGroupInFlight: WeakSet<Page> = new WeakSet();
private isManagedCdpEndpoint(cdpEndpoint: string): boolean {
return (
cdpEndpoint === String(MANAGED_CDP_PORT) ||
cdpEndpoint === `http://localhost:${MANAGED_CDP_PORT}` ||
cdpEndpoint === `http://127.0.0.1:${MANAGED_CDP_PORT}` ||
cdpEndpoint === `ws://127.0.0.1:${MANAGED_CDP_PORT}` ||
cdpEndpoint.includes(`127.0.0.1:${MANAGED_CDP_PORT}/devtools/browser/`) ||
cdpEndpoint.includes(`localhost:${MANAGED_CDP_PORT}/devtools/browser/`)
);
}
private async ensureManagedCdpBrowser(): Promise<void> {
if (await this.probeDebugPort(MANAGED_CDP_PORT)) {
return;
}
const profileDir = getManagedCdpProfileDir();
mkdirSync(profileDir, { recursive: true });
cleanupManagedCdpProfileLocks(profileDir);
const executablePath = findManagedChromeExecutable();
const headed =
process.env.AGENT_BROWSER_HEADED === '1' || process.env.AGENT_BROWSER_HEADED === 'true';
const args = [
'--remote-debugging-address=127.0.0.1',
`--remote-debugging-port=${MANAGED_CDP_PORT}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
];
if (!headed) {
args.push('--headless=new', '--window-size=1280,720');
}
const child = spawn(executablePath, args, {
detached: true,
stdio: 'ignore',
});
child.unref();
const deadline = Date.now() + MANAGED_CDP_START_TIMEOUT_MS;
while (Date.now() < deadline) {
const wsUrl = await this.probeDebugPort(MANAGED_CDP_PORT);
if (wsUrl) {
return;
}
if (child.exitCode !== null) {
throw new Error(`Managed Chrome exited before opening CDP port ${MANAGED_CDP_PORT}`);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for managed Chrome on localhost:${MANAGED_CDP_PORT}`);
}
/**
* Set the persistent color scheme preference.
* Applied automatically to all new pages and contexts.
@@ -2009,7 +2126,15 @@ export class BrowserManager {
}
if (cdpEndpoint) {
await this.connectViaCDP(cdpEndpoint);
try {
await this.connectViaCDP(cdpEndpoint);
} catch (error) {
if (!this.isManagedCdpEndpoint(cdpEndpoint)) {
throw error;
}
await this.ensureManagedCdpBrowser();
await this.connectViaCDP(cdpEndpoint);
}
return;
}
+10 -58
View File
@@ -536,10 +536,10 @@ export async function startDaemon(options?: {
// Auto-launch desktop browser
const launchOptions = buildAutoLaunchOptionsFromEnv();
let attachedToExistingBrowser = false;
let attachedToManagedBrowser = false;
try {
// Keep default CDP attempt minimal. Launch-only options like extensions
// are incompatible with CDP and can cause false-negative attach failures.
// Keep the preferred localhost:9333 path minimal so the daemon can
// connect to or auto-start the dedicated automation Chrome profile.
const cdpLaunchOptions = {
id: launchOptions.id,
action: launchOptions.action,
@@ -553,52 +553,26 @@ export async function startDaemon(options?: {
await manager.launch({
...cdpLaunchOptions,
});
attachedToExistingBrowser = true;
attachedToManagedBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
console.error('[DEBUG] Auto-launch connected via managed CDP port 9333');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
);
console.error(`[DEBUG] Managed CDP port 9333 unavailable: ${message}`);
}
}
if (!attachedToExistingBrowser) {
try {
await manager.launch({
id: launchOptions.id,
action: launchOptions.action,
autoConnect: true,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
tabGroup: launchOptions.tabGroup,
tabGroupPluginId: launchOptions.tabGroupPluginId,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via auto-connect discovery');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(`[DEBUG] Auto-connect discovery failed: ${message}`);
}
}
}
if (!attachedToExistingBrowser) {
if (!attachedToManagedBrowser) {
throw new Error(
'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
'Project policy requires using the dedicated automation browser on localhost:9333. Could not connect to or auto-start the managed Chrome profile.'
);
}
}
}
// For doctor, attempt the same default attach flow but do not fail hard if attach is unavailable.
// For doctor, attempt the same managed localhost:9333 flow but do not fail hard if attach is unavailable.
// This keeps diagnostics actionable even when CDP is down.
if (!manager.isLaunched() && isDoctor && manager instanceof BrowserManager) {
try {
@@ -622,29 +596,7 @@ export async function startDaemon(options?: {
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
});
} catch {
try {
await manager.launch({
id: 'doctor-auto-connect',
action: 'launch',
autoConnect: true,
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
colorScheme:
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
? (process.env.AGENT_BROWSER_COLOR_SCHEME as
| 'dark'
| 'light'
| 'no-preference')
: undefined,
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
tabGroupPluginId:
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
});
} catch {
// Keep running: doctor should report failures instead of exiting early.
}
// Keep running: doctor should report failures instead of exiting early.
}
}