diff --git a/README.md b/README.md index 13062c0..115de9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/src/main.rs b/cli/src/main.rs index fb46e59..b327842 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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 ."; + 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 { diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index ca54378..ccf679b 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -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 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, + cdp_connection: bool, pages: Vec, 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, + headed: bool, + ) -> Result { + 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 diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index 8edbbb2..0153a6b 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -73,6 +73,7 @@ pub struct LaunchOptions { pub ignore_https_errors: bool, pub color_scheme: Option, pub download_path: Option, + pub remote_debugging_port: Option, } 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 { + 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 { }) } +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, + headed: bool, +) -> Result { + 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 { let chrome_path = match &options.executable_path { Some(p) => PathBuf::from(p), diff --git a/cli/src/output.rs b/cli/src/output.rs index d45ac75..3136166 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2461,7 +2461,7 @@ Options: --headed Show browser window (not headless) (or AGENT_BROWSER_HEADED=1/true) --cdp 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 Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH) --tab-group 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): diff --git a/docs/src/app/cdp-mode/page.mdx b/docs/src/app/cdp-mode/page.mdx index 2528a26..a4adcd2 100644 --- a/docs/src/app/cdp-mode/page.mdx +++ b/docs/src/app/cdp-mode/page.mdx @@ -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: diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 7d5b6df..fdb0d76 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -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 diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 0d3f698..5127d24 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -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) diff --git a/src/browser.ts b/src/browser.ts index d5c4bba..2567e23 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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 = new Map(); private tabGroupInFlight: WeakSet = 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 { + 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; } diff --git a/src/daemon.ts b/src/daemon.ts index e43a21a..2e2fa56 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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. } }