From 2a766cfe48fa764fc9506b0ad220ceee0442b269 Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 3 Mar 2026 12:17:14 +0900 Subject: [PATCH] feat: add CDP tab-group plugin handshake with silent fallback --- README.md | 28 +- cli/src/commands.rs | 2 + cli/src/connection.rs | 7 + cli/src/flags.rs | 169 +++++++++- cli/src/main.rs | 33 +- cli/src/output.rs | 13 +- docs/src/app/commands/page.mdx | 21 +- docs/src/app/configuration/page.mdx | 35 +- extensions/tab-group-cdp/content-script.js | 74 +++++ extensions/tab-group-cdp/manifest.json | 19 ++ extensions/tab-group-cdp/service-worker.js | 134 ++++++++ skills/agent-browser/SKILL.md | 23 +- src/actions.test.ts | 129 ++++++- src/actions.ts | 118 ++++++- src/browser.test.ts | 74 +++++ src/browser.ts | 370 ++++++++++++--------- src/daemon.ts | 75 ++--- src/protocol.ts | 1 + src/types.ts | 3 +- 19 files changed, 1079 insertions(+), 249 deletions(-) create mode 100644 extensions/tab-group-cdp/content-script.js create mode 100644 extensions/tab-group-cdp/manifest.json create mode 100644 extensions/tab-group-cdp/service-worker.js diff --git a/README.md b/README.md index b3383b6..700e4b2 100644 --- a/README.md +++ b/README.md @@ -50,20 +50,26 @@ agent-browser snapshot -i agent-browser click @e2 ``` -### Default: Auto Group Agent Tabs (Local Chromium) +### Default: Auto Group Agent Tabs (CDP + Plugin) ```bash agent-browser open https://example.com -# Local Chromium launch auto-groups tabs under "Agent Browser Stealth" +# In CDP mode, tabs are grouped when the tab-group extension is installed # Override group title agent-browser --tab-group "My Agent Group" open https://example.com ``` -- Groups agent-opened tabs under a shared Chrome tab group title. -- Supported only for local Chromium launches. -- In CDP (`--cdp` / `--auto-connect`) and cloud provider modes, it is ignored with a warning. -- Env override: `AGENT_BROWSER_TAB_GROUP`. +- CDP (`--cdp` / `--auto-connect`) keeps working unchanged. +- If the extension is installed and handshake succeeds, agent tabs are grouped by session: + - session=`default`: `Agent Browser Stealth` + - other sessions: `Agent Browser Stealth • ` +- If the extension is missing/unavailable, commands continue normally with silent no-op (no warning/error unless `AGENT_BROWSER_DEBUG=1`). +- Env overrides: + - `AGENT_BROWSER_TAB_GROUP` for base title + - `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` for expected extension ID + +Install once in Chrome: load unpacked extension from `extensions/tab-group-cdp/`. ## Stealth Architecture @@ -175,7 +181,7 @@ Manual overrides are supported: ## Principle 5: Verification-Aware Risk Control -When a navigation lands on verification/captcha pages, structured risk signals are generated from URL/title evidence. +When a navigation lands on verification/captcha pages, structured risk signals are generated from URL/title/page-text evidence. `riskSignals` include: @@ -186,7 +192,7 @@ When a navigation lands on verification/captcha pages, structured risk signals a ### Risk Mode -- `warn` (default): retry with randomized backoff and return warnings + `riskSignals`. +- `warn` (default): wait for auto-clear, then retry with randomized backoff and return warnings + `riskSignals`. - `block`: fail fast once verification/captcha interstitial is detected. - `off`: skip detection/retry path. @@ -198,11 +204,11 @@ AGENT_BROWSER_RISK_MODE=off agent-browser open https://example.com ```mermaid flowchart TD - A["Navigate"] --> B["Collect URL and Title Signals"] + A["Navigate"] --> B["Collect URL/Title/Text Signals"] B --> C{"risk-mode"} C -->|off| D["Return Success"] C -->|block| E["Return Error with First Signal"] - C -->|warn| F["Retry up to 2 times"] + C -->|warn| F["Wait for auto-clear, then retry up to 2 times"] F --> G{"Signals Cleared"} G -->|yes| H["Return Success + recovery warning + riskSignals"] G -->|no| I["Return Success + warning + riskSignals"] @@ -211,7 +217,7 @@ flowchart TD ## Operational Recommendations - Prefer `--headed` for high-friction targets. -- Reuse session state with `--session-name` for continuity. +- Reuse session state with one stable `--session-name` for continuity (when omitted, it defaults to `--session`). - Keep locale/timezone consistent with target market. - Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages. - For `cookies set`, use either `--url `, or `--domain --path ` together. diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 95b370d..0a78581 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -2055,8 +2055,10 @@ mod tests { color_scheme: None, download_path: None, tab_group: None, + tab_group_plugin_id: None, risk_mode: None, cli_tab_group: false, + cli_tab_group_plugin_id: false, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 304b7a8..cadb357 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -228,6 +228,7 @@ pub fn ensure_daemon( debug: bool, download_path: Option<&str>, tab_group: Option<&str>, + tab_group_plugin_id: Option<&str>, ) -> Result { // Check if daemon is running AND responsive if is_daemon_running(session) && daemon_ready(session) { @@ -378,6 +379,9 @@ pub fn ensure_daemon( if let Some(tg) = tab_group { cmd.env("AGENT_BROWSER_TAB_GROUP", tg); } + if let Some(plugin_id) = tab_group_plugin_id { + cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id); + } // Create new process group and session to fully detach unsafe { @@ -468,6 +472,9 @@ pub fn ensure_daemon( if let Some(tg) = tab_group { cmd.env("AGENT_BROWSER_TAB_GROUP", tg); } + if let Some(plugin_id) = tab_group_plugin_id { + cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id); + } // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; diff --git a/cli/src/flags.rs b/cli/src/flags.rs index a3f3488..5b150fe 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -1,4 +1,4 @@ -use crate::color; +use crate::{color, validation}; use serde::Deserialize; use std::env; use std::fs; @@ -7,6 +7,8 @@ use std::path::{Path, PathBuf}; const CONFIG_DIR: &str = ".agent-browser"; const CONFIG_FILENAME: &str = "config.json"; const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json"; +const DEFAULT_TAB_GROUP: &str = "Agent Browser Stealth"; +const DEFAULT_TAB_GROUP_PLUGIN_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; #[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -35,6 +37,7 @@ pub struct Config { pub color_scheme: Option, pub download_path: Option, pub tab_group: Option, + pub tab_group_plugin_id: Option, pub risk_mode: Option, } @@ -71,6 +74,7 @@ impl Config { color_scheme: other.color_scheme.or(self.color_scheme), download_path: other.download_path.or(self.download_path), tab_group: other.tab_group.or(self.tab_group), + tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id), risk_mode: other.risk_mode.or(self.risk_mode), } } @@ -139,6 +143,7 @@ fn extract_config_path(args: &[String]) -> Option> { "--channel", "--download-path", "--tab-group", + "--tab-group-plugin-id", "--risk-mode", ]; let mut i = 0; @@ -206,11 +211,12 @@ pub struct Flags { pub allow_file_access: bool, pub device: Option, pub auto_connect: bool, - pub session_name: Option, + pub session_name: Option, // Defaults to --session when unset pub annotate: bool, pub color_scheme: Option, pub download_path: Option, pub tab_group: Option, + pub tab_group_plugin_id: Option, /// How verification/captcha detections are handled on navigation: /// `off` (disable), `warn` (retry and warn), `block` (fail fast). pub risk_mode: Option, @@ -228,6 +234,7 @@ pub struct Flags { pub cli_annotate: bool, pub cli_download_path: bool, pub cli_tab_group: bool, + pub cli_tab_group_plugin_id: bool, } pub fn parse_flags(args: &[String]) -> Flags { @@ -296,7 +303,14 @@ pub fn parse_flags(args: &[String]) -> Flags { .or(config.color_scheme), download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok() .or(config.download_path), - tab_group: env::var("AGENT_BROWSER_TAB_GROUP").ok().or(config.tab_group), + tab_group: env::var("AGENT_BROWSER_TAB_GROUP") + .ok() + .or(config.tab_group) + .or_else(|| Some(DEFAULT_TAB_GROUP.to_string())), + tab_group_plugin_id: env::var("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID") + .ok() + .or(config.tab_group_plugin_id) + .or_else(|| Some(DEFAULT_TAB_GROUP_PLUGIN_ID.to_string())), risk_mode: env::var("AGENT_BROWSER_RISK_MODE") .ok() .or(config.risk_mode) @@ -312,6 +326,7 @@ pub fn parse_flags(args: &[String]) -> Flags { cli_annotate: false, cli_download_path: false, cli_tab_group: false, + cli_tab_group_plugin_id: false, }; let mut i = 0; @@ -480,6 +495,13 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--tab-group-plugin-id" => { + if let Some(s) = args.get(i + 1) { + flags.tab_group_plugin_id = Some(s.clone()); + flags.cli_tab_group_plugin_id = true; + i += 1; + } + } "--risk-mode" => { if let Some(s) = args.get(i + 1) { flags.risk_mode = Some(s.to_ascii_lowercase()); @@ -494,6 +516,18 @@ pub fn parse_flags(args: &[String]) -> Flags { } i += 1; } + + // Keep auth/state continuity stable by default: if no explicit --session-name + // is provided, derive it from --session (or fall back to "default" when invalid). + if flags.session_name.is_none() { + let derived = if validation::is_valid_session_name(&flags.session) { + flags.session.clone() + } else { + "default".to_string() + }; + flags.session_name = Some(derived); + } + flags } @@ -531,6 +565,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--color-scheme", "--download-path", "--tab-group", + "--tab-group-plugin-id", "--risk-mode", "--config", ]; @@ -566,6 +601,36 @@ pub fn clean_args(args: &[String]) -> Vec { #[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, MutexGuard}; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + struct EnvGuard<'a> { + _lock: MutexGuard<'a, ()>, + vars: Vec<(String, Option)>, + } + + impl<'a> EnvGuard<'a> { + fn new(var_names: &[&str]) -> Self { + let lock = ENV_MUTEX.lock().unwrap(); + let vars = var_names + .iter() + .map(|&name| (name.to_string(), env::var(name).ok())) + .collect(); + Self { _lock: lock, vars } + } + } + + impl Drop for EnvGuard<'_> { + fn drop(&mut self) { + for (name, value) in &self.vars { + match value { + Some(v) => env::set_var(name, v), + None => env::remove_var(name), + } + } + } + } fn args(s: &str) -> Vec { s.split_whitespace().map(String::from).collect() @@ -679,6 +744,19 @@ mod tests { )); assert_eq!(flags.session, "test"); assert_eq!(flags.executable_path, Some("/custom/chrome".to_string())); + assert_eq!(flags.session_name.as_deref(), Some("test")); + } + + #[test] + fn test_session_name_defaults_to_session_when_not_provided() { + let flags = parse_flags(&args("--session my-session snapshot")); + assert_eq!(flags.session_name.as_deref(), Some("my-session")); + } + + #[test] + fn test_invalid_session_falls_back_to_default_session_name() { + let flags = parse_flags(&args("--session bad/session snapshot")); + assert_eq!(flags.session_name.as_deref(), Some("default")); } #[test] @@ -729,6 +807,23 @@ mod tests { assert!(!flags.cli_download_path); } + #[test] + fn test_default_tab_group_is_enabled() { + let flags = parse_flags(&args("snapshot")); + assert_eq!(flags.tab_group.as_deref(), Some(DEFAULT_TAB_GROUP)); + assert!(!flags.cli_tab_group); + } + + #[test] + fn test_default_tab_group_plugin_id_is_enabled() { + let flags = parse_flags(&args("snapshot")); + assert_eq!( + flags.tab_group_plugin_id.as_deref(), + Some(DEFAULT_TAB_GROUP_PLUGIN_ID) + ); + assert!(!flags.cli_tab_group_plugin_id); + } + #[test] fn test_parse_tab_group_flag() { let input = vec![ @@ -747,6 +842,69 @@ mod tests { assert_eq!(cleaned, vec!["open", "example.com"]); } + #[test] + fn test_parse_tab_group_plugin_id_flag() { + let input = vec![ + "--tab-group-plugin-id".to_string(), + "cli-plugin-id".to_string(), + "snapshot".to_string(), + ]; + let flags = parse_flags(&input); + assert_eq!(flags.tab_group_plugin_id.as_deref(), Some("cli-plugin-id")); + assert!(flags.cli_tab_group_plugin_id); + } + + #[test] + fn test_clean_args_removes_tab_group_plugin_id() { + let cleaned = clean_args(&args( + "--tab-group-plugin-id cli-plugin-id open example.com", + )); + assert_eq!(cleaned, vec!["open", "example.com"]); + } + + #[test] + fn test_tab_group_plugin_id_precedence_config_env_cli() { + use std::io::Write; + + let _guard = EnvGuard::new(&["AGENT_BROWSER_TAB_GROUP_PLUGIN_ID"]); + + let dir = std::env::temp_dir().join("ab-test-plugin-id-precedence"); + let _ = fs::create_dir_all(&dir); + let config_path = dir.join("config.json"); + let mut f = fs::File::create(&config_path).unwrap(); + writeln!(f, r#"{{"tabGroupPluginId":"config-plugin-id"}}"#).unwrap(); + + env::set_var("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", "env-plugin-id"); + + let env_args = vec![ + "--config".to_string(), + config_path.to_string_lossy().to_string(), + "snapshot".to_string(), + ]; + let flags_from_env = parse_flags(&env_args); + assert_eq!( + flags_from_env.tab_group_plugin_id.as_deref(), + Some("env-plugin-id") + ); + + let cli_args = vec![ + "--config".to_string(), + config_path.to_string_lossy().to_string(), + "--tab-group-plugin-id".to_string(), + "cli-plugin-id".to_string(), + "snapshot".to_string(), + ]; + let flags_from_cli = parse_flags(&cli_args); + assert_eq!( + flags_from_cli.tab_group_plugin_id.as_deref(), + Some("cli-plugin-id") + ); + assert!(flags_from_cli.cli_tab_group_plugin_id); + + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir(&dir); + } + #[test] fn test_parse_risk_mode_flag() { let flags = parse_flags(&args("--risk-mode block open example.com")); @@ -796,6 +954,7 @@ mod tests { "autoConnect": true, "headers": "{\"Auth\":\"token\"}", "tabGroup": "Agent Browser Stealth", + "tabGroupPluginId": "tab-group-plugin-id", "riskMode": "block" }"#; let config: Config = serde_json::from_str(json).unwrap(); @@ -823,6 +982,10 @@ mod tests { assert_eq!(config.auto_connect, Some(true)); assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}")); assert_eq!(config.tab_group.as_deref(), Some("Agent Browser Stealth")); + assert_eq!( + config.tab_group_plugin_id.as_deref(), + Some("tab-group-plugin-id") + ); assert_eq!(config.risk_mode.as_deref(), Some("block")); } diff --git a/cli/src/main.rs b/cli/src/main.rs index 1dd61b6..e1fe2ef 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -288,6 +288,7 @@ fn main() { flags.debug, flags.download_path.as_deref(), flags.tab_group.as_deref(), + flags.tab_group_plugin_id.as_deref(), ) { Ok(result) => result, Err(e) => { @@ -340,6 +341,9 @@ fn main() { flags.cli_allow_file_access.then_some("--allow-file-access"), flags.cli_download_path.then_some("--download-path"), flags.cli_tab_group.then_some("--tab-group"), + flags + .cli_tab_group_plugin_id + .then_some("--tab-group-plugin-id"), ] .into_iter() .flatten() @@ -429,6 +433,9 @@ fn main() { if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } + if let Some(ref plugin_id) = flags.tab_group_plugin_id { + launch_cmd["tabGroupPluginId"] = json!(plugin_id); + } let err = match send_command(launch_cmd, &flags.session) { Ok(resp) if resp.success => None, @@ -524,6 +531,9 @@ fn main() { if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } + if let Some(ref plugin_id) = flags.tab_group_plugin_id { + launch_cmd["tabGroupPluginId"] = json!(plugin_id); + } let err = match send_command(launch_cmd, &flags.session) { Ok(resp) if resp.success => None, @@ -560,6 +570,9 @@ fn main() { if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } + if let Some(ref plugin_id) = flags.tab_group_plugin_id { + launch_cmd["tabGroupPluginId"] = json!(plugin_id); + } match send_command(launch_cmd, &flags.session) { Ok(resp) => { @@ -600,8 +613,7 @@ fn main() { && flags.user_agent.is_none() && !flags.ignore_https_errors && !flags.allow_file_access - && flags.extensions.is_empty() - && flags.tab_group.is_none(); + && flags.extensions.is_empty(); if can_try_default_cdp { let mut launch_cmd = json!({ @@ -613,6 +625,12 @@ fn main() { if let Some(ref cs) = flags.color_scheme { launch_cmd["colorScheme"] = json!(cs); } + if let Some(ref tg) = flags.tab_group { + launch_cmd["tabGroup"] = json!(tg); + } + if let Some(ref plugin_id) = flags.tab_group_plugin_id { + launch_cmd["tabGroupPluginId"] = json!(plugin_id); + } if let Ok(resp) = send_command(launch_cmd, &flags.session) { attached_to_existing_browser = resp.success; @@ -628,6 +646,12 @@ fn main() { 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; @@ -656,7 +680,7 @@ fn main() { || flags.debug || flags.color_scheme.is_some() || flags.download_path.is_some() - || flags.tab_group.is_some()) + ) && flags.cdp.is_none() && flags.provider.is_none() && !attached_to_existing_browser @@ -724,6 +748,9 @@ fn main() { if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } + if let Some(ref plugin_id) = flags.tab_group_plugin_id { + launch_cmd["tabGroupPluginId"] = json!(plugin_id); + } match send_command(launch_cmd, &flags.session) { Ok(resp) => { diff --git a/cli/src/output.rs b/cli/src/output.rs index 1140275..9146e2f 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2040,7 +2040,8 @@ Operations: clean --older-than Delete expired state files Automatic State Persistence: - Use --session-name to auto-save/restore state across restarts: + Use --session-name to auto-save/restore state across restarts. + If omitted, it defaults to --session (or "default"): agent-browser --session-name myapp open https://example.com Or set AGENT_BROWSER_SESSION_NAME environment variable. @@ -2409,9 +2410,10 @@ Options: Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback) --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 Override default tab group title for agent tabs in Chromium local launch (or AGENT_BROWSER_TAB_GROUP) + --tab-group Base title for agent tab groups (CDP plugin mode; silent no-op if plugin unavailable) + --tab-group-plugin-id Expected Chrome extension ID for tab-group handshake (or AGENT_BROWSER_TAB_GROUP_PLUGIN_ID) --risk-mode Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE) - --session-name Auto-save/restore session state (cookies, localStorage) + --session-name Auto-save/restore session state (defaults to --session) --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES) --max-output Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) --allowed-domains Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS) @@ -2449,7 +2451,7 @@ Configuration: Environment: AGENT_BROWSER_CONFIG Path to config file (or use --config) AGENT_BROWSER_SESSION Session name (default: "default") - AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name + AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name (default: AGENT_BROWSER_SESSION) AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30) AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path @@ -2468,7 +2470,8 @@ Environment: AGENT_BROWSER_TIMEZONE Override auto-detected timezone (e.g., Asia/Taipei) AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference) AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads - AGENT_BROWSER_TAB_GROUP Override default tab group title (Chromium local launch only) + AGENT_BROWSER_TAB_GROUP Base title for tab groups (default: "Agent Browser Stealth"; session suffix auto-appended) + AGENT_BROWSER_TAB_GROUP_PLUGIN_ID Expected Chrome extension ID for tab-group handshake (default: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block) AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000) AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index ece23e8..09b2796 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -116,7 +116,7 @@ agent-browser wait --download [path] # Wait for download Control how `open`/`navigate` handles verification or captcha interstitials: ```bash -agent-browser --risk-mode warn open https://example.com # default: retry and warn with riskSignals +agent-browser --risk-mode warn open https://example.com # default: wait for auto-clear, then retry/warn with riskSignals agent-browser --risk-mode block open https://example.com # fail fast on detection agent-browser --risk-mode off open https://example.com # disable detection/retry ``` @@ -134,17 +134,21 @@ Use `--download-path ` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a defa ```bash agent-browser open https://example.com -# Local Chromium launch auto-groups tabs under "Agent Browser Stealth" +# CDP mode groups tabs when tab-group plugin is installed # Override the default group title agent-browser --tab-group "My Agent Group" open https://example.com ``` -Local Chromium launches auto-create/reuse the `Agent Browser Stealth` tab group and move newly opened agent tabs into that group. +CDP mode uses a browser extension handshake to group tabs. -- Supported only for local Chromium launches. -- In CDP (`--cdp` / `--auto-connect`) and cloud provider modes, the flag is ignored with a warning. -- Use `--tab-group` or `AGENT_BROWSER_TAB_GROUP` to override the default group title. +- Extension available: tabs are grouped by `session`. +- Extension missing/unavailable: silent no-op (commands still succeed). +- Default titles: + - `default` session: `Agent Browser Stealth` + - non-default: `Agent Browser Stealth • ` +- Use `--tab-group` / `AGENT_BROWSER_TAB_GROUP` for base title. +- Use `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` (or `--tab-group-plugin-id`) to override expected extension ID. ## Mouse @@ -276,7 +280,7 @@ agent-browser reload # Reload page ```bash --session # Isolated browser session ---session-name # Auto-save/restore session state (cookies, localStorage) +--session-name # Auto-save/restore session state (defaults to --session when omitted) --state # Load storage state from JSON file --headers # HTTP headers scoped to URL's origin --executable-path # Custom browser executable @@ -296,7 +300,8 @@ agent-browser reload # Reload page --headed # Show browser window (not headless) --cdp # Connect via Chrome DevTools Protocol (port or WebSocket URL) --auto-connect # Auto-discover and connect to running Chrome ---tab-group # Override default agent tab group title (Chromium local launch only) +--tab-group # Base title for agent tab groups (CDP plugin mode) +--tab-group-plugin-id # Expected extension ID for tab-group handshake --debug # Debug output (includes stealth connection type + capabilities) ``` diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index ae33d01..87d07a2 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -281,7 +281,16 @@ Every CLI flag can be set in the config file using its camelCase equivalent: --tab-group - string (override default tab group title; Chromium local launch only) + string (base title for session tab grouping via CDP plugin handshake) + + + + tabGroupPluginId + + + --tab-group-plugin-id + + string (expected extension ID for tab-group plugin handshake) @@ -308,6 +317,9 @@ Every CLI flag can be set in the config file using its camelCase equivalent: `riskMode` defaults to `warn` when unset. +For tab grouping in CDP mode, grouping is best-effort through the extension handshake: +extension available => grouped by session; extension missing/unavailable => silent no-op. + ## Common Configurations ### Local Development @@ -419,8 +431,21 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_TAB_GROUP - Override default auto-group title for agent tabs (Chromium local launch only). - (disabled) + + Base title for tab grouping. Session suffix is appended automatically in CDP mode. + + + Agent Browser Stealth + + + + + AGENT_BROWSER_TAB_GROUP_PLUGIN_ID + + Expected extension ID for CDP tab-group plugin handshake. + + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + @@ -447,8 +472,8 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_SESSION_NAME - Auto-save/load state persistence name. - (none) + Auto-save/load state persistence name (defaults to AGENT_BROWSER_SESSION when unset). + (same as AGENT_BROWSER_SESSION) diff --git a/extensions/tab-group-cdp/content-script.js b/extensions/tab-group-cdp/content-script.js new file mode 100644 index 0000000..451989e --- /dev/null +++ b/extensions/tab-group-cdp/content-script.js @@ -0,0 +1,74 @@ +(() => { + const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST'; + const RESPONSE_TYPE = 'AB_TAB_GROUP_RESPONSE'; + + window.addEventListener('message', (event) => { + if (event.source !== window) { + return; + } + + const data = event.data; + if (!data || data.type !== REQUEST_TYPE) { + return; + } + + let request; + try { + request = { + type: REQUEST_TYPE, + nonce: data.nonce, + session: data.session, + groupTitle: data.groupTitle, + pluginId: data.pluginId, + }; + } catch { + return; + } + + try { + chrome.runtime.sendMessage(request, (response) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + window.postMessage( + { + type: RESPONSE_TYPE, + nonce: request.nonce, + ok: false, + error: lastError.message, + }, + '*' + ); + return; + } + + const payload = response && typeof response === 'object' ? response : { ok: false }; + + window.postMessage( + { + type: RESPONSE_TYPE, + nonce: request.nonce, + ok: payload.ok === true, + extensionId: + typeof payload.extensionId === 'string' && payload.extensionId.length > 0 + ? payload.extensionId + : chrome.runtime.id, + groupId: typeof payload.groupId === 'number' ? payload.groupId : undefined, + error: typeof payload.error === 'string' ? payload.error : undefined, + }, + '*' + ); + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + window.postMessage( + { + type: RESPONSE_TYPE, + nonce: request.nonce, + ok: false, + error: errorMessage, + }, + '*' + ); + } + }); +})(); diff --git a/extensions/tab-group-cdp/manifest.json b/extensions/tab-group-cdp/manifest.json new file mode 100644 index 0000000..13b260d --- /dev/null +++ b/extensions/tab-group-cdp/manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "Agent Browser CDP Tab Grouper", + "version": "0.1.0", + "description": "Groups tabs by Agent Browser session when requested from CDP-driven pages.", + "permissions": ["tabs", "tabGroups"], + "host_permissions": [""], + "background": { + "service_worker": "service-worker.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content-script.js"], + "run_at": "document_start", + "match_about_blank": true + } + ] +} diff --git a/extensions/tab-group-cdp/service-worker.js b/extensions/tab-group-cdp/service-worker.js new file mode 100644 index 0000000..ef69b7f --- /dev/null +++ b/extensions/tab-group-cdp/service-worker.js @@ -0,0 +1,134 @@ +const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST'; +const DEFAULT_GROUP_TITLE = 'Agent Browser Stealth'; +const sessionGroupCache = new Map(); + +function normalizeSession(session) { + if (typeof session !== 'string') return 'default'; + const trimmed = session.trim(); + return trimmed.length > 0 ? trimmed.slice(0, 64) : 'default'; +} + +function normalizeGroupTitle(title) { + if (typeof title !== 'string') return DEFAULT_GROUP_TITLE; + const trimmed = title.trim(); + return trimmed.length > 0 ? trimmed.slice(0, 80) : DEFAULT_GROUP_TITLE; +} + +function cacheKey(windowId, session) { + return `${windowId}:${session}`; +} + +async function findExistingGroup(windowId, groupTitle) { + const tabs = await chrome.tabs.query({ windowId }); + const checked = new Set(); + + for (const tab of tabs) { + if (typeof tab.groupId !== 'number' || tab.groupId < 0 || checked.has(tab.groupId)) { + continue; + } + + checked.add(tab.groupId); + try { + const group = await chrome.tabGroups.get(tab.groupId); + if (group.title === groupTitle) { + return tab.groupId; + } + } catch { + // Ignore stale group references and continue. + } + } + + return null; +} + +async function ensureSessionGroup(tabId, windowId, session, groupTitle) { + const key = cacheKey(windowId, session); + let groupId = sessionGroupCache.get(key); + + if (typeof groupId === 'number') { + try { + await chrome.tabGroups.get(groupId); + } catch { + groupId = undefined; + } + } + + if (typeof groupId !== 'number') { + const existing = await findExistingGroup(windowId, groupTitle); + if (typeof existing === 'number') { + groupId = existing; + } + } + + if (typeof groupId === 'number') { + await chrome.tabs.group({ groupId, tabIds: [tabId] }); + } else { + groupId = await chrome.tabs.group({ + tabIds: [tabId], + createProperties: { windowId }, + }); + } + + await chrome.tabGroups.update(groupId, { + title: groupTitle, + color: 'blue', + collapsed: false, + }); + + sessionGroupCache.set(key, groupId); + return groupId; +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || message.type !== REQUEST_TYPE) { + return; + } + + const tabId = sender.tab?.id; + const windowId = sender.tab?.windowId; + const nonce = typeof message.nonce === 'string' ? message.nonce : undefined; + + if (typeof tabId !== 'number' || typeof windowId !== 'number') { + sendResponse({ + ok: false, + error: 'missing-tab-context', + extensionId: chrome.runtime.id, + nonce, + }); + return; + } + + if (typeof message.pluginId === 'string' && message.pluginId !== chrome.runtime.id) { + sendResponse({ + ok: false, + error: 'plugin-id-mismatch', + extensionId: chrome.runtime.id, + nonce, + }); + return; + } + + const session = normalizeSession(message.session); + const groupTitle = normalizeGroupTitle(message.groupTitle); + + ensureSessionGroup(tabId, windowId, session, groupTitle) + .then((groupId) => { + sendResponse({ + ok: true, + groupId, + extensionId: chrome.runtime.id, + nonce, + }); + }) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + sendResponse({ + ok: false, + error: errorMessage, + extensionId: chrome.runtime.id, + nonce, + }); + }); + + return true; +}); diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index c68a098..1288934 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -89,7 +89,7 @@ agent-browser wait 2000-5000 # Random wait between 2-5 seconds agent-browser download @e1 ./file.pdf # Click element to trigger download agent-browser wait --download ./output.zip # Wait for any download to complete agent-browser --download-path ./downloads open # Set default download directory -agent-browser --tab-group "My Agent Group" open # Override default tab group title (Chromium local launch) +agent-browser --tab-group "My Agent Group" open # Override default tab-group base title # Capture agent-browser screenshot # Screenshot to temp dir @@ -172,6 +172,7 @@ agent-browser cookies set callback_token "token123" ```bash # Auto-save/restore cookies and localStorage across browser restarts +# If --session-name is omitted, it defaults to --session (or "default") agent-browser --session-name myapp open https://app.example.com/login # ... login flow ... agent-browser close # State auto-saved to ~/.agent-browser/sessions/ @@ -251,7 +252,7 @@ agent-browser set media dark ### Tab Grouping ```bash -# Local Chromium launch auto-groups under "Agent Browser Stealth" +# CDP mode groups tabs when tab-group extension is installed agent-browser open https://example.com # Override the default group title @@ -259,13 +260,19 @@ agent-browser --tab-group "My Agent Group" open https://example.com # Or via environment variable AGENT_BROWSER_TAB_GROUP="My Agent Group" agent-browser open https://example.com + +# Override expected extension ID if needed +AGENT_BROWSER_TAB_GROUP_PLUGIN_ID="" agent-browser open https://example.com ``` Notes: -- Works only for local Chromium launches. -- In CDP/auto-connect and cloud provider modes, `--tab-group` is ignored with a warning. -- New agent tabs are auto-added to the group after each tab loads content. +- Works in CDP mode via extension handshake. +- Extension installed and reachable: tabs are grouped by session. +- Extension missing/unavailable: silent no-op (no warning/error unless debug mode). +- Default titles: + - `default` session: `Agent Browser Stealth` + - non-default session: `Agent Browser Stealth • ` ### Visual Browser (Debugging) @@ -298,7 +305,7 @@ Stealth is always active -- no flags needed. All sessions automatically apply an Chromium launches in managed mode use Chrome channel by default for a genuine browser binary fingerprint. -For best results against strong bot detection, use `--headed` and `--session-name`. +For best results against strong bot detection, use `--headed` and keep one stable `--session-name`. ### Auto Region Detection @@ -310,7 +317,7 @@ Override: `AGENT_BROWSER_LOCALE`, `AGENT_BROWSER_TIMEZONE` env vars. When a navigation lands on a captcha/verification page, behavior is controlled by `--risk-mode` (or `AGENT_BROWSER_RISK_MODE`): -- `warn` (default): retry up to 2 times with randomized backoff (3-7s), then return warning plus structured `riskSignals` +- `warn` (default): wait for auto-clear first, then retry up to 2 times with randomized backoff (3-7s), then return warning plus structured `riskSignals` - `block`: fail fast once a risk interstitial is detected - `off`: disable this detection/retry path @@ -459,7 +466,7 @@ agent-browser automatically humanizes interactions to avoid behavioral detection - **Random wait ranges**: `wait 2000-5000` pauses for a random duration in that range - **Bezier curve mouse**: Before every `click`, the mouse moves along a natural-looking curve -These behaviors are always active. For sensitive sites, combine with `--headed` and `--session-name` for best results. +These behaviors are always active. For sensitive sites, combine with `--headed` and a stable `--session-name` for best results. ## Session Management and Cleanup diff --git a/src/actions.test.ts b/src/actions.test.ts index 0087075..d48571e 100644 --- a/src/actions.test.ts +++ b/src/actions.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { detectRiskSignals, toAIFriendlyError } from './actions.js'; +import { describe, it, expect, vi } from 'vitest'; +import { detectRiskSignals, executeCommand, toAIFriendlyError } from './actions.js'; describe('toAIFriendlyError', () => { describe('element blocked by overlay', () => { @@ -55,4 +55,129 @@ describe('detectRiskSignals', () => { const signals = detectRiskSignals('https://example.com/dashboard', 'Dashboard'); expect(signals).toEqual([]); }); + + it('should detect cloudflare security verification text', () => { + const signals = detectRiskSignals( + 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm', + 'dash.cloudflare.com', + 'Performing security verification Verifying... This website uses a security service to protect against malicious bots.' + ); + expect(signals.some((s) => s.code === 'verification_interstitial')).toBe(true); + expect(signals.some((s) => s.code === 'bot_challenge')).toBe(true); + }); +}); + +describe('tab grouping fallback', () => { + it('should keep navigate successful when tab grouping trigger throws', async () => { + const page = { + waitForTimeout: vi.fn().mockResolvedValue(undefined), + goto: vi.fn().mockResolvedValue(undefined), + url: vi.fn().mockReturnValue('https://example.com/'), + title: vi.fn().mockResolvedValue('Example Domain'), + }; + + const browser = { + getPage: vi.fn().mockReturnValue(page), + setTargetUrl: vi.fn().mockResolvedValue(undefined), + triggerTabGroupingForActivePage: vi.fn().mockImplementation(() => { + throw new Error('plugin-unavailable'); + }), + }; + + const response = await executeCommand( + { id: 'n1', action: 'navigate', url: 'https://example.com', riskMode: 'off' }, + browser as any + ); + + expect(response.success).toBe(true); + expect(page.goto).toHaveBeenCalledTimes(1); + }); + + it('should keep tab_new successful when tab grouping trigger throws after navigation', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + }; + + const browser = { + newTab: vi.fn().mockResolvedValue({ index: 1, total: 2 }), + getPage: vi.fn().mockReturnValue(page), + triggerTabGroupingForActivePage: vi.fn().mockImplementation(() => { + throw new Error('plugin-unavailable'); + }), + }; + + const response = await executeCommand( + { id: 't1', action: 'tab_new', url: 'https://example.com' }, + browser as any + ); + + expect(response.success).toBe(true); + expect(browser.newTab).toHaveBeenCalledTimes(1); + expect(page.goto).toHaveBeenCalledTimes(1); + }); +}); + +describe('risk interstitial recovery', () => { + it('should wait for cloudflare-style challenge to clear before retrying navigation', async () => { + const challengeClearMs = 10_000; + let challengeElapsed = 0; + let currentUrl = 'https://dash.cloudflare.com/challenge'; + let currentTitle = 'Just a moment...'; + + const syncChallengeState = () => { + if (challengeElapsed >= challengeClearMs) { + currentUrl = 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm'; + currentTitle = 'Cloudflare Dashboard'; + } else { + currentUrl = 'https://dash.cloudflare.com/challenge'; + currentTitle = 'Just a moment...'; + } + }; + + const page = { + waitForTimeout: vi.fn().mockImplementation(async (ms: number) => { + challengeElapsed += Number(ms) || 0; + syncChallengeState(); + }), + goto: vi.fn().mockImplementation(async () => { + // Refreshing during verification resets challenge progress. + if (challengeElapsed < challengeClearMs) { + challengeElapsed = 0; + } + syncChallengeState(); + }), + url: vi.fn().mockImplementation(() => currentUrl), + title: vi.fn().mockImplementation(async () => currentTitle), + evaluate: vi.fn().mockImplementation(async () => { + if (currentTitle === 'Just a moment...') { + return 'Performing security verification Verifying... This website uses a security service to protect against malicious bots.'; + } + return 'Dashboard content'; + }), + }; + + const browser = { + getPage: vi.fn().mockReturnValue(page), + setTargetUrl: vi.fn().mockResolvedValue(undefined), + triggerTabGroupingForActivePage: vi.fn(), + }; + + const response = await executeCommand( + { + id: 'cf1', + action: 'navigate', + url: 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm', + riskMode: 'warn', + }, + browser as any + ); + + expect(response.success).toBe(true); + if (response.success) { + expect(response.data.title).toBe('Cloudflare Dashboard'); + expect(response.data.warning).toContain('cleared after wait'); + expect(response.data.riskSignals?.length ?? 0).toBeGreaterThan(0); + } + expect(page.goto).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/actions.ts b/src/actions.ts index d1fbf13..15bb9c1 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -545,6 +545,11 @@ async function handleNavigate( await page.goto(command.url, { waitUntil: command.waitUntil ?? 'load', }); + try { + browser.triggerTabGroupingForActivePage('navigate'); + } catch { + // Tab-grouping is best-effort and must never fail navigation. + } const riskMode: RiskMode = command.riskMode ?? 'warn'; if (riskMode === 'off') { @@ -557,7 +562,7 @@ async function handleNavigate( // Detect risk interstitials (captcha/verification) and handle by risk mode. const finalUrl = page.url(); const title = await page.title(); - let encounteredSignals = detectRiskSignals(finalUrl, title); + let encounteredSignals = await detectPageRiskSignals(page, finalUrl, title); if (encounteredSignals.length === 0) { return successResponse(command.id, { url: finalUrl, @@ -573,16 +578,43 @@ async function handleNavigate( ); } + // Many verification interstitials (e.g. Cloudflare) auto-resolve after a short wait. + // Poll before forcing a retry to avoid resetting the challenge loop ourselves. + const initialRecovery = await waitForRiskRecovery(page, 12_000); + if (initialRecovery.recovered) { + return successResponse(command.id, { + url: initialRecovery.url, + title: initialRecovery.title, + warning: + 'Risk interstitial detected and cleared after wait. Reuse the same browser session for stability.', + riskSignals: encounteredSignals, + }); + } + encounteredSignals = mergeRiskSignals(encounteredSignals, initialRecovery.signals); + const maxRetries = 2; for (let attempt = 1; attempt <= maxRetries; attempt++) { const backoff = 3000 + Math.random() * 4000; await page.waitForTimeout(Math.round(backoff)); + + const passiveRecovery = await waitForRiskRecovery(page, 8_000); + if (passiveRecovery.recovered) { + return successResponse(command.id, { + url: passiveRecovery.url, + title: passiveRecovery.title, + warning: + 'Risk interstitial detected and recovered after wait. Review riskSignals for evidence.', + riskSignals: encounteredSignals, + }); + } + encounteredSignals = mergeRiskSignals(encounteredSignals, passiveRecovery.signals); + await page.goto(command.url, { waitUntil: command.waitUntil ?? 'load', }); const retryUrl = page.url(); const retryTitle = await page.title(); - const retrySignals = detectRiskSignals(retryUrl, retryTitle); + const retrySignals = await detectPageRiskSignals(page, retryUrl, retryTitle); if (retrySignals.length === 0) { return successResponse(command.id, { url: retryUrl, @@ -600,7 +632,7 @@ async function handleNavigate( url: page.url(), title: await page.title(), warning: - 'Captcha/verification page detected. Try --headed mode or use --session-name for state persistence.', + 'Captcha/verification page detected. Keep one stable --session-name and retry in the same browser window.', riskSignals: encounteredSignals, }); } @@ -616,12 +648,57 @@ function mergeRiskSignals(current: RiskSignal[], next: RiskSignal[]): RiskSignal return [...merged.values()]; } +async function detectPageRiskSignals( + page: Page, + currentUrl?: string, + currentTitle?: string +): Promise { + const url = currentUrl ?? page.url(); + const title = currentTitle ?? (await page.title()); + let pageText = ''; + try { + pageText = await page.evaluate(() => { + const text = (globalThis as any).document?.body?.innerText ?? ''; + return String(text).slice(0, 2000); + }); + } catch { + // Ignore cross-origin/script-restricted pages; URL/title signals still apply. + } + return detectRiskSignals(url, title, pageText); +} + +async function waitForRiskRecovery( + page: Page, + timeoutMs: number +): Promise<{ recovered: boolean; url: string; title: string; signals: RiskSignal[] }> { + const deadline = Date.now() + timeoutMs; + let url = page.url(); + let title = await page.title(); + let signals = await detectPageRiskSignals(page, url, title); + + while (signals.length > 0 && Date.now() < deadline) { + const remaining = deadline - Date.now(); + await page.waitForTimeout(Math.min(1000, Math.max(250, remaining))); + url = page.url(); + title = await page.title(); + signals = await detectPageRiskSignals(page, url, title); + } + + return { + recovered: signals.length === 0, + url, + title, + signals, + }; +} + /** * Detect verification/captcha interstitials and return structured risk evidence. */ -export function detectRiskSignals(url: string, title: string): RiskSignal[] { +export function detectRiskSignals(url: string, title: string, pageText: string = ''): RiskSignal[] { const lowerUrl = url.toLowerCase(); const lowerTitle = title.toLowerCase(); + const lowerText = pageText.toLowerCase(); const urlPatterns: Array<{ pattern: string; code: string; confidence: number }> = [ { pattern: '/verify/captcha', code: 'captcha_interstitial', confidence: 0.98 }, { pattern: '/captcha', code: 'captcha_interstitial', confidence: 0.95 }, @@ -637,12 +714,30 @@ export function detectRiskSignals(url: string, title: string): RiskSignal[] { { pattern: 'challenge', code: 'verification_interstitial', confidence: 0.8 }, { pattern: 'attention required', code: 'verification_interstitial', confidence: 0.96 }, { pattern: 'just a moment', code: 'verification_interstitial', confidence: 0.95 }, + { + pattern: 'performing security verification', + code: 'verification_interstitial', + confidence: 0.98, + }, { pattern: 'checking your browser', code: 'verification_interstitial', confidence: 0.97 }, { pattern: 'access denied', code: 'access_gate', confidence: 0.86 }, { pattern: '驗證', code: 'verification_interstitial', confidence: 0.88 }, { pattern: '验证', code: 'verification_interstitial', confidence: 0.88 }, { pattern: '人机验证', code: 'captcha_interstitial', confidence: 0.95 }, ]; + const textPatterns: Array<{ pattern: string; code: string; confidence: number }> = [ + { + pattern: 'performing security verification', + code: 'verification_interstitial', + confidence: 0.99, + }, + { + pattern: 'this website uses a security service to protect against malicious bots', + code: 'bot_challenge', + confidence: 0.99, + }, + { pattern: 'verifying...', code: 'verification_interstitial', confidence: 0.84 }, + ]; const signals: RiskSignal[] = []; for (const item of urlPatterns) { if (lowerUrl.includes(item.pattern)) { @@ -664,6 +759,16 @@ export function detectRiskSignals(url: string, title: string): RiskSignal[] { }); } } + for (const item of textPatterns) { + if (lowerText.includes(item.pattern)) { + signals.push({ + code: item.code, + source: 'title', + evidence: item.pattern, + confidence: item.confidence, + }); + } + } return mergeRiskSignals([], signals); } @@ -1152,6 +1257,11 @@ async function handleTabNew( if (command.url) { const page = browser.getPage(); await page.goto(command.url, { waitUntil: 'domcontentloaded' }); + try { + browser.triggerTabGroupingForActivePage('tab-new-navigate'); + } catch { + // Tab-grouping is best-effort and must never fail tab creation. + } } return successResponse(command.id, result); diff --git a/src/browser.test.ts b/src/browser.test.ts index 10e98ad..f1d4892 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -262,6 +262,80 @@ describe('BrowserManager', () => { }); }); + describe('tab-group plugin handshake', () => { + it('should mark plugin capability as available after successful handshake', async () => { + const manager = new BrowserManager() as any; + manager.tabGroupIntent = { + session: 'default', + groupTitle: 'Agent Browser Stealth', + pluginId: 'plugin-123', + }; + manager.stealthConnectionKind = 'cdp'; + + const page = { + isClosed: () => false, + url: () => 'https://example.com', + }; + + const requestSpy = vi + .spyOn(manager, 'requestTabGroupPlugin') + .mockResolvedValue({ ok: true, extensionId: 'plugin-123' }); + + await manager.tryApplyTabGrouping(page, 'test'); + + expect(manager.getTabGroupCapability('default')).toBe('available'); + expect(requestSpy).toHaveBeenCalledTimes(1); + requestSpy.mockRestore(); + }); + + it('should silently mark capability unavailable on timeout response', async () => { + const manager = new BrowserManager() as any; + manager.tabGroupIntent = { + session: 'default', + groupTitle: 'Agent Browser Stealth', + pluginId: 'plugin-123', + }; + manager.stealthConnectionKind = 'cdp'; + + const page = { + isClosed: () => false, + url: () => 'https://example.com', + }; + + const requestSpy = vi.spyOn(manager, 'requestTabGroupPlugin').mockResolvedValue(null); + + await manager.tryApplyTabGrouping(page, 'test-timeout'); + + expect(manager.getTabGroupCapability('default')).toBe('unavailable'); + expect(requestSpy).toHaveBeenCalledTimes(1); + requestSpy.mockRestore(); + }); + + it('should stop retrying handshake once capability is unavailable', async () => { + const manager = new BrowserManager() as any; + manager.tabGroupIntent = { + session: 'default', + groupTitle: 'Agent Browser Stealth', + pluginId: 'plugin-123', + }; + manager.stealthConnectionKind = 'cdp'; + + const page = { + isClosed: () => false, + url: () => 'https://example.com', + }; + + const requestSpy = vi.spyOn(manager, 'requestTabGroupPlugin').mockResolvedValue(null); + + await manager.tryApplyTabGrouping(page, 'first-attempt'); + await manager.tryApplyTabGrouping(page, 'second-attempt'); + + expect(manager.getTabGroupCapability('default')).toBe('unavailable'); + expect(requestSpy).toHaveBeenCalledTimes(1); + requestSpy.mockRestore(); + }); + }); + describe('stale session recovery (all pages closed)', () => { it('should recover when all pages are closed externally', async () => { const testBrowser = new BrowserManager(); diff --git a/src/browser.ts b/src/browser.ts index 260fa21..4174c2e 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -16,15 +16,7 @@ import { } from 'playwright-core'; import path from 'node:path'; import os from 'node:os'; -import { - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - readFileSync, - statSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs'; import { writeFile, mkdir } from 'node:fs/promises'; import type { LaunchCommand, TraceEvent } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; @@ -136,6 +128,18 @@ interface StealthContextDefaults { const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/']; 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'; +const TAB_GROUP_RESPONSE_MESSAGE_TYPE = 'AB_TAB_GROUP_RESPONSE'; +const TAB_GROUP_REQUEST_TIMEOUT_MS = 400; + +type TabGroupPluginAvailability = 'unknown' | 'available' | 'unavailable'; + +interface TabGroupIntent { + session: string; + groupTitle: string; + pluginId: string; +} /** * Manages the Playwright browser lifecycle with multiple tabs/windows @@ -172,7 +176,9 @@ export class BrowserManager { private contextUserAgent: string | undefined = undefined; private downloadPath: string | null = null; private allowedDomains: string[] = []; - private tabGroupExtensionDir: string | null = null; + private tabGroupIntent: TabGroupIntent | null = null; + private tabGroupCapabilityBySession: Map = new Map(); + private tabGroupInFlight: WeakSet = new WeakSet(); /** * Set the persistent color scheme preference. @@ -496,115 +502,202 @@ export class BrowserManager { return trimmed.slice(0, 80); } - /** - * Build a temporary MV3 extension that auto-groups managed tabs under a fixed title. - * This is only used for local Chromium launches. - */ - private createTabGroupExtension(groupTitle: string): string { - this.cleanupTabGroupExtension(); + private normalizeTabGroupPluginId(pluginId?: string): string | undefined { + if (!pluginId) return undefined; + const trimmed = pluginId.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, 128); + } - const extensionDir = mkdtempSync(path.join(os.tmpdir(), 'agent-browser-tab-group-')); - const manifest = { - manifest_version: 3, - name: 'Agent Browser Tab Grouper', - version: '1.0.0', - permissions: ['tabs', 'tabGroups'], - host_permissions: [''], - background: { - service_worker: 'service-worker.js', + private getAgentSessionName(): string { + const session = process.env.AGENT_BROWSER_SESSION?.trim(); + return session && session.length > 0 ? session : 'default'; + } + + private buildSessionTabGroupTitle(baseTitle: string, session: string): string { + const normalizedBase = this.normalizeTabGroupName(baseTitle) ?? DEFAULT_TAB_GROUP_NAME; + if (session === 'default') { + return normalizedBase; + } + const withSuffix = `${normalizedBase} • ${session}`; + return this.normalizeTabGroupName(withSuffix) ?? normalizedBase; + } + + private configureTabGroupIntent(options: LaunchCommand): void { + const baseTitle = this.normalizeTabGroupName(options.tabGroup) ?? DEFAULT_TAB_GROUP_NAME; + const session = this.getAgentSessionName(); + const groupTitle = this.buildSessionTabGroupTitle(baseTitle, session); + const pluginId = + this.normalizeTabGroupPluginId(options.tabGroupPluginId) ?? + this.normalizeTabGroupPluginId(process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID) ?? + DEFAULT_TAB_GROUP_PLUGIN_ID; + + this.tabGroupIntent = { session, groupTitle, pluginId }; + if (!this.tabGroupCapabilityBySession.has(session)) { + this.tabGroupCapabilityBySession.set(session, 'unknown'); + } + } + + private getTabGroupCapability(session: string): TabGroupPluginAvailability { + return this.tabGroupCapabilityBySession.get(session) ?? 'unknown'; + } + + private setTabGroupCapability(session: string, capability: TabGroupPluginAvailability): void { + this.tabGroupCapabilityBySession.set(session, capability); + } + + private canInjectTabGroupScript(page: Page): boolean { + const url = this.getSafePageUrl(page).toLowerCase(); + if (!url) return false; + return ( + !url.startsWith('chrome://') && + !url.startsWith('chrome-extension://') && + !url.startsWith('devtools://') && + !url.startsWith('edge://') + ); + } + + private logTabGroupDebug(message: string): void { + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error(`[DEBUG] ${message}`); + } + } + + private async requestTabGroupPlugin( + page: Page, + intent: TabGroupIntent + ): Promise<{ ok: boolean; extensionId?: string; error?: string } | null> { + const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + const result = await page.evaluate( + ({ requestType, responseType, nonce, session, groupTitle, pluginId, timeoutMs }) => { + return new Promise<{ + ok: boolean; + extensionId?: string; + error?: string; + } | null>((resolve) => { + let settled = false; + let timer: number | undefined; + + const finish = (value: { ok: boolean; extensionId?: string; error?: string } | null) => { + if (settled) return; + settled = true; + window.removeEventListener('message', onMessage); + if (typeof timer === 'number') { + window.clearTimeout(timer); + } + resolve(value); + }; + + const onMessage = (event: MessageEvent) => { + if (event.source !== window) return; + const data = event.data as Record | null; + if (!data || data.type !== responseType) return; + if (data.nonce !== nonce) return; + finish({ + ok: data.ok === true, + extensionId: + typeof data.extensionId === 'string' && data.extensionId.length > 0 + ? data.extensionId + : undefined, + error: typeof data.error === 'string' ? data.error : undefined, + }); + }; + + window.addEventListener('message', onMessage); + timer = window.setTimeout(() => finish(null), timeoutMs); + + try { + window.postMessage( + { + type: requestType, + nonce, + session, + groupTitle, + pluginId, + }, + '*' + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + finish({ ok: false, error: message }); + } + }); }, - content_scripts: [ - { - matches: [''], - js: ['content-script.js'], - run_at: 'document_start', - match_about_blank: true, - }, - ], - }; - - const serviceWorker = `const GROUP_TITLE = ${JSON.stringify(groupTitle)}; -const MESSAGE_TYPE = 'agent-browser-manage-tab'; - -async function findGroupId(windowId) { - const tabs = await chrome.tabs.query({ windowId }); - const checkedGroupIds = new Set(); - for (const tab of tabs) { - if (typeof tab.groupId !== 'number' || tab.groupId < 0 || checkedGroupIds.has(tab.groupId)) { - continue; - } - checkedGroupIds.add(tab.groupId); - try { - const group = await chrome.tabGroups.get(tab.groupId); - if (group.title === GROUP_TITLE) { - return tab.groupId; + { + requestType: TAB_GROUP_REQUEST_MESSAGE_TYPE, + responseType: TAB_GROUP_RESPONSE_MESSAGE_TYPE, + nonce, + session: intent.session, + groupTitle: intent.groupTitle, + pluginId: intent.pluginId, + timeoutMs: TAB_GROUP_REQUEST_TIMEOUT_MS, } - } catch { - // Ignore stale group IDs and continue searching. + ); + + return result; + } + + private scheduleTabGrouping(page: Page, source: string): void { + void this.tryApplyTabGrouping(page, source); + } + + private async tryApplyTabGrouping(page: Page, source: string): Promise { + const intent = this.tabGroupIntent; + if (!intent) return; + if (this.stealthConnectionKind !== 'cdp') return; + if (this.tabGroupInFlight.has(page)) return; + + const capability = this.getTabGroupCapability(intent.session); + if (capability === 'unavailable') return; + + if (page.isClosed() || !this.canInjectTabGroupScript(page)) { + return; + } + + this.tabGroupInFlight.add(page); + + try { + const response = await this.requestTabGroupPlugin(page, intent); + if (!response) { + this.setTabGroupCapability(intent.session, 'unavailable'); + this.logTabGroupDebug( + `Tab-group plugin unavailable (timeout, source=${source}, session=${intent.session})` + ); + return; + } + + if (!response.ok) { + this.setTabGroupCapability(intent.session, 'unavailable'); + this.logTabGroupDebug( + `Tab-group plugin returned error (source=${source}, session=${intent.session}): ${response.error ?? 'unknown'}` + ); + return; + } + + if (response.extensionId !== intent.pluginId) { + this.setTabGroupCapability(intent.session, 'unavailable'); + this.logTabGroupDebug( + `Tab-group plugin id mismatch (source=${source}, expected=${intent.pluginId}, actual=${response.extensionId ?? 'missing'})` + ); + return; + } + + this.setTabGroupCapability(intent.session, 'available'); + } catch (error) { + this.setTabGroupCapability(intent.session, 'unavailable'); + const message = error instanceof Error ? error.message : String(error); + this.logTabGroupDebug( + `Tab-group plugin unavailable (source=${source}, session=${intent.session}): ${message}` + ); + } finally { + this.tabGroupInFlight.delete(page); } } - return null; -} -async function styleGroup(groupId) { - await chrome.tabGroups.update(groupId, { - title: GROUP_TITLE, - color: 'blue', - collapsed: false, - }); -} - -async function ensureTabGrouped(tabId, windowId) { - let groupId = await findGroupId(windowId); - if (groupId === null) { - groupId = await chrome.tabs.group({ - tabIds: [tabId], - createProperties: { windowId }, - }); - await styleGroup(groupId); - return; - } - await chrome.tabs.group({ - groupId, - tabIds: [tabId], - }); - await styleGroup(groupId); -} - -chrome.runtime.onMessage.addListener((message, sender) => { - if (!message || message.type !== MESSAGE_TYPE) { - return; - } - const tabId = sender.tab?.id; - const windowId = sender.tab?.windowId; - if (typeof tabId !== 'number' || typeof windowId !== 'number') { - return; - } - ensureTabGrouped(tabId, windowId).catch(() => {}); -}); -`; - - const contentScript = `(() => { - try { - chrome.runtime.sendMessage({ type: 'agent-browser-manage-tab' }); - } catch { - // Ignore pages where extension messaging is unavailable. - } -})(); -`; - - writeFileSync(path.join(extensionDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); - writeFileSync(path.join(extensionDir, 'service-worker.js'), serviceWorker); - writeFileSync(path.join(extensionDir, 'content-script.js'), contentScript); - - this.tabGroupExtensionDir = extensionDir; - return extensionDir; - } - - private cleanupTabGroupExtension(): void { - if (!this.tabGroupExtensionDir) return; - rmSync(this.tabGroupExtensionDir, { recursive: true, force: true }); - this.tabGroupExtensionDir = null; + triggerTabGroupingForActivePage(source: string = 'active-page'): void { + if (!this.tabGroupIntent || this.pages.length === 0) return; + const page = this.getPage(); + this.scheduleTabGrouping(page, source); } // CDP profiling state @@ -1719,9 +1812,6 @@ chrome.runtime.onMessage.addListener((message, sender) => { const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined); const configuredExtensions = options.extensions ? [...options.extensions] : []; const hasStorageState = !!options.storageState; - const explicitTabGroup = this.normalizeTabGroupName(options.tabGroup); - const requestedTabGroup = explicitTabGroup ?? DEFAULT_TAB_GROUP_NAME; - const tabGroupWasExplicit = explicitTabGroup !== undefined; if (configuredExtensions.length > 0 && cdpEndpoint) { throw new Error('Extensions cannot be used with CDP connection'); @@ -1762,6 +1852,7 @@ chrome.runtime.onMessage.addListener((message, sender) => { this.contextTimezoneId = this.resolveStealthTimezoneId(); this.contextHeaders = undefined; this.contextUserAgent = options.userAgent; + this.configureTabGroupIntent(options); // -p flag takes precedence over AGENT_BROWSER_PROVIDER. const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER; @@ -1777,42 +1868,7 @@ chrome.runtime.onMessage.addListener((message, sender) => { this.stealthConnectionKind = 'local'; } this.logStealthPolicy('launch policy', options.browser ?? 'chromium'); - - let effectiveExtensions = configuredExtensions; - if (requestedTabGroup) { - const requestedBrowserType = options.browser ?? 'chromium'; - if (this.stealthConnectionKind !== 'local') { - if (tabGroupWasExplicit) { - const warning = `--tab-group "${requestedTabGroup}" is ignored in CDP/provider mode (requires local Chromium launch)`; - this.launchWarnings.push(warning); - console.error(`[WARN] ${warning}`); - } - } else if (requestedBrowserType !== 'chromium') { - if (tabGroupWasExplicit) { - const warning = `--tab-group is only supported in Chromium (requested: ${requestedBrowserType})`; - this.launchWarnings.push(warning); - console.error(`[WARN] ${warning}`); - } - } else if (options.headless === true) { - if (tabGroupWasExplicit) { - const warning = '--tab-group is ignored in headless mode'; - this.launchWarnings.push(warning); - console.error(`[WARN] ${warning}`); - } - } else if (hasStorageState) { - if (tabGroupWasExplicit) { - const warning = - '--tab-group is ignored when storage state is loaded via --state (extensions require persistent context)'; - this.launchWarnings.push(warning); - console.error(`[WARN] ${warning}`); - } - } else { - const tabGroupExtensionPath = this.createTabGroupExtension(requestedTabGroup); - effectiveExtensions = [...effectiveExtensions, tabGroupExtensionPath]; - } - } - - const hasExtensions = effectiveExtensions.length > 0; + const hasExtensions = configuredExtensions.length > 0; if (options.downloadPath) { this.downloadPath = options.downloadPath; @@ -1953,7 +2009,7 @@ chrome.runtime.onMessage.addListener((message, sender) => { let context: BrowserContext; if (hasExtensions) { // Extensions require persistent context in a temp directory - const extPaths = effectiveExtensions.join(','); + const extPaths = configuredExtensions.join(','); const session = process.env.AGENT_BROWSER_SESSION || 'default'; // Combine extension args with custom args and file access args const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`]; @@ -2511,6 +2567,8 @@ chrome.runtime.onMessage.addListener((message, sender) => { // Invalidate CDP session since the active page changed this.invalidateCDPSession().catch(() => {}); } + + this.scheduleTabGrouping(page, 'context-page'); }); } @@ -2533,6 +2591,7 @@ chrome.runtime.onMessage.addListener((message, sender) => { this.setupPageTracking(page); } this.activePageIndex = this.pages.length - 1; + this.scheduleTabGrouping(page, 'new-tab'); return { index: this.activePageIndex, total: this.pages.length }; } @@ -3285,8 +3344,6 @@ chrome.runtime.onMessage.addListener((message, sender) => { } } - this.cleanupTabGroupExtension(); - this.pages = []; this.contexts = []; this.cdpEndpoint = null; @@ -3305,6 +3362,9 @@ chrome.runtime.onMessage.addListener((message, sender) => { this.contextTimezoneId = undefined; this.contextHeaders = undefined; this.contextUserAgent = undefined; + this.tabGroupIntent = null; + this.tabGroupCapabilityBySession.clear(); + this.tabGroupInFlight = new WeakSet(); this.refMap = {}; this.lastSnapshot = ''; this.frameCallback = null; diff --git a/src/daemon.ts b/src/daemon.ts index 9685f77..6801e85 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -465,6 +465,7 @@ export async function startDaemon(options?: { ? colorSchemeEnv : undefined; const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim(); + const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim(); const launchOptions = { id: 'auto', action: 'launch' as const, @@ -480,53 +481,42 @@ export async function startDaemon(options?: { colorScheme, tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined, + tabGroupPluginId: + tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined, autoStateFilePath: getSessionAutoStatePath(), }; let attachedToExistingBrowser = false; - if (launchOptions.tabGroup) { - try { - await manager.launch(launchOptions); - attachedToExistingBrowser = true; - if (process.env.AGENT_BROWSER_DEBUG === '1') { - console.error('[DEBUG] Auto-launch started local Chromium with --tab-group'); - } - } catch (error) { - if (process.env.AGENT_BROWSER_DEBUG === '1') { - const message = error instanceof Error ? error.message : String(error); - console.error(`[DEBUG] Local launch with --tab-group failed: ${message}`); - } + try { + // Keep default CDP attempt minimal. Launch-only options like extensions + // are incompatible with CDP and can cause false-negative attach failures. + const cdpLaunchOptions = { + id: launchOptions.id, + action: launchOptions.action, + cdpPort: 9333, + ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, + colorScheme: launchOptions.colorScheme, + userAgent: launchOptions.userAgent, + tabGroup: launchOptions.tabGroup, + tabGroupPluginId: launchOptions.tabGroupPluginId, + }; + await manager.launch({ + ...cdpLaunchOptions, + }); + attachedToExistingBrowser = true; + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error('[DEBUG] Auto-launch connected via default CDP port 9333'); } - } else { - try { - // Keep default CDP attempt minimal. Launch-only options like extensions - // are incompatible with CDP and can cause false-negative attach failures. - const cdpLaunchOptions = { - id: launchOptions.id, - action: launchOptions.action, - cdpPort: 9333, - ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, - colorScheme: launchOptions.colorScheme, - userAgent: launchOptions.userAgent, - }; - await manager.launch({ - ...cdpLaunchOptions, - }); - attachedToExistingBrowser = true; - if (process.env.AGENT_BROWSER_DEBUG === '1') { - console.error('[DEBUG] Auto-launch connected via default 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}` - ); - } + } 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}` + ); } } - if (!attachedToExistingBrowser && !launchOptions.tabGroup) { + if (!attachedToExistingBrowser) { try { await manager.launch({ id: launchOptions.id, @@ -535,6 +525,8 @@ export async function startDaemon(options?: { ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, colorScheme: launchOptions.colorScheme, userAgent: launchOptions.userAgent, + tabGroup: launchOptions.tabGroup, + tabGroupPluginId: launchOptions.tabGroupPluginId, }); attachedToExistingBrowser = true; if (process.env.AGENT_BROWSER_DEBUG === '1') { @@ -549,11 +541,6 @@ export async function startDaemon(options?: { } if (!attachedToExistingBrowser) { - if (launchOptions.tabGroup) { - throw new Error( - 'Failed to launch local Chromium with tab grouping. Check Chromium availability and extension policy settings.' - ); - } throw new Error( 'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.' ); diff --git a/src/protocol.ts b/src/protocol.ts index ad1d916..e436474 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -52,6 +52,7 @@ const launchSchema = baseCommandSchema.extend({ colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(), downloadPath: z.string().optional(), tabGroup: z.string().min(1).optional(), + tabGroupPluginId: z.string().min(1).optional(), storageState: z.string().optional(), allowedDomains: z.array(z.string()).optional(), actionPolicy: z.string().optional(), diff --git a/src/types.ts b/src/types.ts index 3406b5b..1e58b33 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,7 +41,8 @@ export interface LaunchCommand extends BaseCommand { allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath) - tabGroup?: string; // Chromium local-launch only: auto-group agent tabs under this title + tabGroup?: string; // Base tab-group title (session suffix is appended automatically) + tabGroupPluginId?: string; // Expected Chrome extension ID for CDP tab-group handshake allowedDomains?: string[]; actionPolicy?: string; confirmActions?: string[];