feat: add CDP tab-group plugin handshake with silent fallback

This commit is contained in:
leeguooooo
2026-03-03 12:17:14 +09:00
parent d04cf59238
commit 2a766cfe48
19 changed files with 1079 additions and 249 deletions
+17 -11
View File
@@ -50,20 +50,26 @@ agent-browser snapshot -i
agent-browser click @e2 agent-browser click @e2
``` ```
### Default: Auto Group Agent Tabs (Local Chromium) ### Default: Auto Group Agent Tabs (CDP + Plugin)
```bash ```bash
agent-browser open https://example.com 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 # Override group title
agent-browser --tab-group "My Agent Group" open https://example.com agent-browser --tab-group "My Agent Group" open https://example.com
``` ```
- Groups agent-opened tabs under a shared Chrome tab group title. - CDP (`--cdp` / `--auto-connect`) keeps working unchanged.
- Supported only for local Chromium launches. - If the extension is installed and handshake succeeds, agent tabs are grouped by session:
- In CDP (`--cdp` / `--auto-connect`) and cloud provider modes, it is ignored with a warning. - session=`default`: `Agent Browser Stealth`
- Env override: `AGENT_BROWSER_TAB_GROUP`. - other sessions: `Agent Browser Stealth • <session>`
- 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 ## Stealth Architecture
@@ -175,7 +181,7 @@ Manual overrides are supported:
## Principle 5: Verification-Aware Risk Control ## 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: `riskSignals` include:
@@ -186,7 +192,7 @@ When a navigation lands on verification/captcha pages, structured risk signals a
### Risk Mode ### 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. - `block`: fail fast once verification/captcha interstitial is detected.
- `off`: skip detection/retry path. - `off`: skip detection/retry path.
@@ -198,11 +204,11 @@ AGENT_BROWSER_RISK_MODE=off agent-browser open https://example.com
```mermaid ```mermaid
flowchart TD flowchart TD
A["Navigate"] --> B["Collect URL and Title Signals"] A["Navigate"] --> B["Collect URL/Title/Text Signals"]
B --> C{"risk-mode"} B --> C{"risk-mode"}
C -->|off| D["Return Success"] C -->|off| D["Return Success"]
C -->|block| E["Return Error with First Signal"] 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"} F --> G{"Signals Cleared"}
G -->|yes| H["Return Success + recovery warning + riskSignals"] G -->|yes| H["Return Success + recovery warning + riskSignals"]
G -->|no| I["Return Success + warning + riskSignals"] G -->|no| I["Return Success + warning + riskSignals"]
@@ -211,7 +217,7 @@ flowchart TD
## Operational Recommendations ## Operational Recommendations
- Prefer `--headed` for high-friction targets. - 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. - Keep locale/timezone consistent with target market.
- Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages. - Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages.
- For `cookies set`, use either `--url <url>`, or `--domain <domain> --path <path>` together. - For `cookies set`, use either `--url <url>`, or `--domain <domain> --path <path>` together.
+2
View File
@@ -2055,8 +2055,10 @@ mod tests {
color_scheme: None, color_scheme: None,
download_path: None, download_path: None,
tab_group: None, tab_group: None,
tab_group_plugin_id: None,
risk_mode: None, risk_mode: None,
cli_tab_group: false, cli_tab_group: false,
cli_tab_group_plugin_id: false,
} }
} }
+7
View File
@@ -228,6 +228,7 @@ pub fn ensure_daemon(
debug: bool, debug: bool,
download_path: Option<&str>, download_path: Option<&str>,
tab_group: Option<&str>, tab_group: Option<&str>,
tab_group_plugin_id: Option<&str>,
) -> Result<DaemonResult, String> { ) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive // Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) { if is_daemon_running(session) && daemon_ready(session) {
@@ -378,6 +379,9 @@ pub fn ensure_daemon(
if let Some(tg) = tab_group { if let Some(tg) = tab_group {
cmd.env("AGENT_BROWSER_TAB_GROUP", tg); 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 // Create new process group and session to fully detach
unsafe { unsafe {
@@ -468,6 +472,9 @@ pub fn ensure_daemon(
if let Some(tg) = tab_group { if let Some(tg) = tab_group {
cmd.env("AGENT_BROWSER_TAB_GROUP", tg); 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 // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
+166 -3
View File
@@ -1,4 +1,4 @@
use crate::color; use crate::{color, validation};
use serde::Deserialize; use serde::Deserialize;
use std::env; use std::env;
use std::fs; use std::fs;
@@ -7,6 +7,8 @@ use std::path::{Path, PathBuf};
const CONFIG_DIR: &str = ".agent-browser"; const CONFIG_DIR: &str = ".agent-browser";
const CONFIG_FILENAME: &str = "config.json"; const CONFIG_FILENAME: &str = "config.json";
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.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)] #[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
@@ -35,6 +37,7 @@ pub struct Config {
pub color_scheme: Option<String>, pub color_scheme: Option<String>,
pub download_path: Option<String>, pub download_path: Option<String>,
pub tab_group: Option<String>, pub tab_group: Option<String>,
pub tab_group_plugin_id: Option<String>,
pub risk_mode: Option<String>, pub risk_mode: Option<String>,
} }
@@ -71,6 +74,7 @@ impl Config {
color_scheme: other.color_scheme.or(self.color_scheme), color_scheme: other.color_scheme.or(self.color_scheme),
download_path: other.download_path.or(self.download_path), download_path: other.download_path.or(self.download_path),
tab_group: other.tab_group.or(self.tab_group), 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), risk_mode: other.risk_mode.or(self.risk_mode),
} }
} }
@@ -139,6 +143,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--channel", "--channel",
"--download-path", "--download-path",
"--tab-group", "--tab-group",
"--tab-group-plugin-id",
"--risk-mode", "--risk-mode",
]; ];
let mut i = 0; let mut i = 0;
@@ -206,11 +211,12 @@ pub struct Flags {
pub allow_file_access: bool, pub allow_file_access: bool,
pub device: Option<String>, pub device: Option<String>,
pub auto_connect: bool, pub auto_connect: bool,
pub session_name: Option<String>, pub session_name: Option<String>, // Defaults to --session when unset
pub annotate: bool, pub annotate: bool,
pub color_scheme: Option<String>, pub color_scheme: Option<String>,
pub download_path: Option<String>, pub download_path: Option<String>,
pub tab_group: Option<String>, pub tab_group: Option<String>,
pub tab_group_plugin_id: Option<String>,
/// How verification/captcha detections are handled on navigation: /// How verification/captcha detections are handled on navigation:
/// `off` (disable), `warn` (retry and warn), `block` (fail fast). /// `off` (disable), `warn` (retry and warn), `block` (fail fast).
pub risk_mode: Option<String>, pub risk_mode: Option<String>,
@@ -228,6 +234,7 @@ pub struct Flags {
pub cli_annotate: bool, pub cli_annotate: bool,
pub cli_download_path: bool, pub cli_download_path: bool,
pub cli_tab_group: bool, pub cli_tab_group: bool,
pub cli_tab_group_plugin_id: bool,
} }
pub fn parse_flags(args: &[String]) -> Flags { pub fn parse_flags(args: &[String]) -> Flags {
@@ -296,7 +303,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.color_scheme), .or(config.color_scheme),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok() download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
.or(config.download_path), .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") risk_mode: env::var("AGENT_BROWSER_RISK_MODE")
.ok() .ok()
.or(config.risk_mode) .or(config.risk_mode)
@@ -312,6 +326,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_annotate: false, cli_annotate: false,
cli_download_path: false, cli_download_path: false,
cli_tab_group: false, cli_tab_group: false,
cli_tab_group_plugin_id: false,
}; };
let mut i = 0; let mut i = 0;
@@ -480,6 +495,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1; 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" => { "--risk-mode" => {
if let Some(s) = args.get(i + 1) { if let Some(s) = args.get(i + 1) {
flags.risk_mode = Some(s.to_ascii_lowercase()); flags.risk_mode = Some(s.to_ascii_lowercase());
@@ -494,6 +516,18 @@ pub fn parse_flags(args: &[String]) -> Flags {
} }
i += 1; 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 flags
} }
@@ -531,6 +565,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--color-scheme", "--color-scheme",
"--download-path", "--download-path",
"--tab-group", "--tab-group",
"--tab-group-plugin-id",
"--risk-mode", "--risk-mode",
"--config", "--config",
]; ];
@@ -566,6 +601,36 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::{Mutex, MutexGuard};
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvGuard<'a> {
_lock: MutexGuard<'a, ()>,
vars: Vec<(String, Option<String>)>,
}
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<String> { fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect() s.split_whitespace().map(String::from).collect()
@@ -679,6 +744,19 @@ mod tests {
)); ));
assert_eq!(flags.session, "test"); assert_eq!(flags.session, "test");
assert_eq!(flags.executable_path, Some("/custom/chrome".to_string())); 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] #[test]
@@ -729,6 +807,23 @@ mod tests {
assert!(!flags.cli_download_path); 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] #[test]
fn test_parse_tab_group_flag() { fn test_parse_tab_group_flag() {
let input = vec![ let input = vec![
@@ -747,6 +842,69 @@ mod tests {
assert_eq!(cleaned, vec!["open", "example.com"]); 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] #[test]
fn test_parse_risk_mode_flag() { fn test_parse_risk_mode_flag() {
let flags = parse_flags(&args("--risk-mode block open example.com")); let flags = parse_flags(&args("--risk-mode block open example.com"));
@@ -796,6 +954,7 @@ mod tests {
"autoConnect": true, "autoConnect": true,
"headers": "{\"Auth\":\"token\"}", "headers": "{\"Auth\":\"token\"}",
"tabGroup": "Agent Browser Stealth", "tabGroup": "Agent Browser Stealth",
"tabGroupPluginId": "tab-group-plugin-id",
"riskMode": "block" "riskMode": "block"
}"#; }"#;
let config: Config = serde_json::from_str(json).unwrap(); 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.auto_connect, Some(true));
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}")); 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.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")); assert_eq!(config.risk_mode.as_deref(), Some("block"));
} }
+30 -3
View File
@@ -288,6 +288,7 @@ fn main() {
flags.debug, flags.debug,
flags.download_path.as_deref(), flags.download_path.as_deref(),
flags.tab_group.as_deref(), flags.tab_group.as_deref(),
flags.tab_group_plugin_id.as_deref(),
) { ) {
Ok(result) => result, Ok(result) => result,
Err(e) => { Err(e) => {
@@ -340,6 +341,9 @@ fn main() {
flags.cli_allow_file_access.then_some("--allow-file-access"), flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_download_path.then_some("--download-path"), flags.cli_download_path.then_some("--download-path"),
flags.cli_tab_group.then_some("--tab-group"), flags.cli_tab_group.then_some("--tab-group"),
flags
.cli_tab_group_plugin_id
.then_some("--tab-group-plugin-id"),
] ]
.into_iter() .into_iter()
.flatten() .flatten()
@@ -429,6 +433,9 @@ fn main() {
if let Some(ref tg) = flags.tab_group { if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg); 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) { let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None, Ok(resp) if resp.success => None,
@@ -524,6 +531,9 @@ fn main() {
if let Some(ref tg) = flags.tab_group { if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg); 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) { let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None, Ok(resp) if resp.success => None,
@@ -560,6 +570,9 @@ fn main() {
if let Some(ref tg) = flags.tab_group { if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg); 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) { match send_command(launch_cmd, &flags.session) {
Ok(resp) => { Ok(resp) => {
@@ -600,8 +613,7 @@ fn main() {
&& flags.user_agent.is_none() && flags.user_agent.is_none()
&& !flags.ignore_https_errors && !flags.ignore_https_errors
&& !flags.allow_file_access && !flags.allow_file_access
&& flags.extensions.is_empty() && flags.extensions.is_empty();
&& flags.tab_group.is_none();
if can_try_default_cdp { if can_try_default_cdp {
let mut launch_cmd = json!({ let mut launch_cmd = json!({
@@ -613,6 +625,12 @@ fn main() {
if let Some(ref cs) = flags.color_scheme { if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs); 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) { if let Ok(resp) = send_command(launch_cmd, &flags.session) {
attached_to_existing_browser = resp.success; attached_to_existing_browser = resp.success;
@@ -628,6 +646,12 @@ fn main() {
if let Some(ref cs) = flags.color_scheme { if let Some(ref cs) = flags.color_scheme {
auto_connect_cmd["colorScheme"] = json!(cs); 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) { if let Ok(resp) = send_command(auto_connect_cmd, &flags.session) {
attached_to_existing_browser = resp.success; attached_to_existing_browser = resp.success;
@@ -656,7 +680,7 @@ fn main() {
|| flags.debug || flags.debug
|| flags.color_scheme.is_some() || flags.color_scheme.is_some()
|| flags.download_path.is_some() || flags.download_path.is_some()
|| flags.tab_group.is_some()) )
&& flags.cdp.is_none() && flags.cdp.is_none()
&& flags.provider.is_none() && flags.provider.is_none()
&& !attached_to_existing_browser && !attached_to_existing_browser
@@ -724,6 +748,9 @@ fn main() {
if let Some(ref tg) = flags.tab_group { if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg); 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) { match send_command(launch_cmd, &flags.session) {
Ok(resp) => { Ok(resp) => {
+8 -5
View File
@@ -2040,7 +2040,8 @@ Operations:
clean --older-than <days> Delete expired state files clean --older-than <days> Delete expired state files
Automatic State Persistence: 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 agent-browser --session-name myapp open https://example.com
Or set AGENT_BROWSER_SESSION_NAME environment variable. 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) Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) --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) --download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
--tab-group <name> Override default tab group title for agent tabs in Chromium local launch (or AGENT_BROWSER_TAB_GROUP) --tab-group <name> Base title for agent tab groups (CDP plugin mode; silent no-op if plugin unavailable)
--tab-group-plugin-id <id> Expected Chrome extension ID for tab-group handshake (or AGENT_BROWSER_TAB_GROUP_PLUGIN_ID)
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE) --risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
--session-name <name> Auto-save/restore session state (cookies, localStorage) --session-name <name> Auto-save/restore session state (defaults to --session)
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES) --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) --max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS) --allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
@@ -2449,7 +2451,7 @@ Configuration:
Environment: Environment:
AGENT_BROWSER_CONFIG Path to config file (or use --config) AGENT_BROWSER_CONFIG Path to config file (or use --config)
AGENT_BROWSER_SESSION Session name (default: "default") 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_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_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path 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_TIMEZONE Override auto-detected timezone (e.g., Asia/Taipei)
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference) AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads 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_RISK_MODE Verify/captcha handling mode (off, warn, block)
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000) AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
+13 -8
View File
@@ -116,7 +116,7 @@ agent-browser wait --download [path] # Wait for download
Control how `open`/`navigate` handles verification or captcha interstitials: Control how `open`/`navigate` handles verification or captcha interstitials:
```bash ```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 block open https://example.com # fail fast on detection
agent-browser --risk-mode off open https://example.com # disable detection/retry agent-browser --risk-mode off open https://example.com # disable detection/retry
``` ```
@@ -134,17 +134,21 @@ Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a defa
```bash ```bash
agent-browser open https://example.com 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 # Override the default group title
agent-browser --tab-group "My Agent Group" open https://example.com 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. - Extension available: tabs are grouped by `session`.
- In CDP (`--cdp` / `--auto-connect`) and cloud provider modes, the flag is ignored with a warning. - Extension missing/unavailable: silent no-op (commands still succeed).
- Use `--tab-group` or `AGENT_BROWSER_TAB_GROUP` to override the default group title. - Default titles:
- `default` session: `Agent Browser Stealth`
- non-default: `Agent Browser Stealth • <session>`
- 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 ## Mouse
@@ -276,7 +280,7 @@ agent-browser reload # Reload page
```bash ```bash
--session <name> # Isolated browser session --session <name> # Isolated browser session
--session-name <name> # Auto-save/restore session state (cookies, localStorage) --session-name <name> # Auto-save/restore session state (defaults to --session when omitted)
--state <path> # Load storage state from JSON file --state <path> # Load storage state from JSON file
--headers <json> # HTTP headers scoped to URL's origin --headers <json> # HTTP headers scoped to URL's origin
--executable-path <path> # Custom browser executable --executable-path <path> # Custom browser executable
@@ -296,7 +300,8 @@ agent-browser reload # Reload page
--headed # Show browser window (not headless) --headed # Show browser window (not headless)
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL) --cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
--auto-connect # Auto-discover and connect to running Chrome --auto-connect # Auto-discover and connect to running Chrome
--tab-group <name> # Override default agent tab group title (Chromium local launch only) --tab-group <name> # Base title for agent tab groups (CDP plugin mode)
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
--debug # Debug output (includes stealth connection type + capabilities) --debug # Debug output (includes stealth connection type + capabilities)
``` ```
+30 -5
View File
@@ -281,7 +281,16 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
<td> <td>
<code>--tab-group</code> <code>--tab-group</code>
</td> </td>
<td>string (override default tab group title; Chromium local launch only)</td> <td>string (base title for session tab grouping via CDP plugin handshake)</td>
</tr>
<tr>
<td>
<code>tabGroupPluginId</code>
</td>
<td>
<code>--tab-group-plugin-id</code>
</td>
<td>string (expected extension ID for tab-group plugin handshake)</td>
</tr> </tr>
<tr> <tr>
<td> <td>
@@ -308,6 +317,9 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
`riskMode` defaults to `warn` when unset. `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 ## Common Configurations
### Local Development ### Local Development
@@ -419,8 +431,21 @@ These environment variables configure additional daemon and runtime behavior:
<td> <td>
<code>AGENT_BROWSER_TAB_GROUP</code> <code>AGENT_BROWSER_TAB_GROUP</code>
</td> </td>
<td>Override default auto-group title for agent tabs (Chromium local launch only).</td> <td>
<td>(disabled)</td> Base title for tab grouping. Session suffix is appended automatically in CDP mode.
</td>
<td>
<code>Agent Browser Stealth</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_TAB_GROUP_PLUGIN_ID</code>
</td>
<td>Expected extension ID for CDP tab-group plugin handshake.</td>
<td>
<code>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code>
</td>
</tr> </tr>
<tr> <tr>
<td> <td>
@@ -447,8 +472,8 @@ These environment variables configure additional daemon and runtime behavior:
<td> <td>
<code>AGENT_BROWSER_SESSION_NAME</code> <code>AGENT_BROWSER_SESSION_NAME</code>
</td> </td>
<td>Auto-save/load state persistence name.</td> <td>Auto-save/load state persistence name (defaults to <code>AGENT_BROWSER_SESSION</code> when unset).</td>
<td>(none)</td> <td>(same as <code>AGENT_BROWSER_SESSION</code>)</td>
</tr> </tr>
<tr> <tr>
<td> <td>
@@ -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,
},
'*'
);
}
});
})();
+19
View File
@@ -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": ["<all_urls>"],
"background": {
"service_worker": "service-worker.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.js"],
"run_at": "document_start",
"match_about_blank": true
}
]
}
+134
View File
@@ -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;
});
+15 -8
View File
@@ -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 download @e1 ./file.pdf # Click element to trigger download
agent-browser wait --download ./output.zip # Wait for any download to complete agent-browser wait --download ./output.zip # Wait for any download to complete
agent-browser --download-path ./downloads open <url> # Set default download directory agent-browser --download-path ./downloads open <url> # Set default download directory
agent-browser --tab-group "My Agent Group" open <url> # Override default tab group title (Chromium local launch) agent-browser --tab-group "My Agent Group" open <url> # Override default tab-group base title
# Capture # Capture
agent-browser screenshot # Screenshot to temp dir agent-browser screenshot # Screenshot to temp dir
@@ -172,6 +172,7 @@ agent-browser cookies set callback_token "token123"
```bash ```bash
# Auto-save/restore cookies and localStorage across browser restarts # 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 agent-browser --session-name myapp open https://app.example.com/login
# ... login flow ... # ... login flow ...
agent-browser close # State auto-saved to ~/.agent-browser/sessions/ agent-browser close # State auto-saved to ~/.agent-browser/sessions/
@@ -251,7 +252,7 @@ agent-browser set media dark
### Tab Grouping ### Tab Grouping
```bash ```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 agent-browser open https://example.com
# Override the default group title # Override the default group title
@@ -259,13 +260,19 @@ agent-browser --tab-group "My Agent Group" open https://example.com
# Or via environment variable # Or via environment variable
AGENT_BROWSER_TAB_GROUP="My Agent Group" agent-browser open https://example.com 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="<extension-id>" agent-browser open https://example.com
``` ```
Notes: Notes:
- Works only for local Chromium launches. - Works in CDP mode via extension handshake.
- In CDP/auto-connect and cloud provider modes, `--tab-group` is ignored with a warning. - Extension installed and reachable: tabs are grouped by session.
- New agent tabs are auto-added to the group after each tab loads content. - 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 • <session>`
### Visual Browser (Debugging) ### 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. 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 ### 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`): 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 - `block`: fail fast once a risk interstitial is detected
- `off`: disable this detection/retry path - `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 - **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 - **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 ## Session Management and Cleanup
+127 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { detectRiskSignals, toAIFriendlyError } from './actions.js'; import { detectRiskSignals, executeCommand, toAIFriendlyError } from './actions.js';
describe('toAIFriendlyError', () => { describe('toAIFriendlyError', () => {
describe('element blocked by overlay', () => { describe('element blocked by overlay', () => {
@@ -55,4 +55,129 @@ describe('detectRiskSignals', () => {
const signals = detectRiskSignals('https://example.com/dashboard', 'Dashboard'); const signals = detectRiskSignals('https://example.com/dashboard', 'Dashboard');
expect(signals).toEqual([]); 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);
});
}); });
+114 -4
View File
@@ -545,6 +545,11 @@ async function handleNavigate(
await page.goto(command.url, { await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load', 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'; const riskMode: RiskMode = command.riskMode ?? 'warn';
if (riskMode === 'off') { if (riskMode === 'off') {
@@ -557,7 +562,7 @@ async function handleNavigate(
// Detect risk interstitials (captcha/verification) and handle by risk mode. // Detect risk interstitials (captcha/verification) and handle by risk mode.
const finalUrl = page.url(); const finalUrl = page.url();
const title = await page.title(); const title = await page.title();
let encounteredSignals = detectRiskSignals(finalUrl, title); let encounteredSignals = await detectPageRiskSignals(page, finalUrl, title);
if (encounteredSignals.length === 0) { if (encounteredSignals.length === 0) {
return successResponse(command.id, { return successResponse(command.id, {
url: finalUrl, 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; const maxRetries = 2;
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
const backoff = 3000 + Math.random() * 4000; const backoff = 3000 + Math.random() * 4000;
await page.waitForTimeout(Math.round(backoff)); 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, { await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load', waitUntil: command.waitUntil ?? 'load',
}); });
const retryUrl = page.url(); const retryUrl = page.url();
const retryTitle = await page.title(); const retryTitle = await page.title();
const retrySignals = detectRiskSignals(retryUrl, retryTitle); const retrySignals = await detectPageRiskSignals(page, retryUrl, retryTitle);
if (retrySignals.length === 0) { if (retrySignals.length === 0) {
return successResponse(command.id, { return successResponse(command.id, {
url: retryUrl, url: retryUrl,
@@ -600,7 +632,7 @@ async function handleNavigate(
url: page.url(), url: page.url(),
title: await page.title(), title: await page.title(),
warning: 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, riskSignals: encounteredSignals,
}); });
} }
@@ -616,12 +648,57 @@ function mergeRiskSignals(current: RiskSignal[], next: RiskSignal[]): RiskSignal
return [...merged.values()]; return [...merged.values()];
} }
async function detectPageRiskSignals(
page: Page,
currentUrl?: string,
currentTitle?: string
): Promise<RiskSignal[]> {
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. * 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 lowerUrl = url.toLowerCase();
const lowerTitle = title.toLowerCase(); const lowerTitle = title.toLowerCase();
const lowerText = pageText.toLowerCase();
const urlPatterns: Array<{ pattern: string; code: string; confidence: number }> = [ const urlPatterns: Array<{ pattern: string; code: string; confidence: number }> = [
{ pattern: '/verify/captcha', code: 'captcha_interstitial', confidence: 0.98 }, { pattern: '/verify/captcha', code: 'captcha_interstitial', confidence: 0.98 },
{ pattern: '/captcha', code: 'captcha_interstitial', confidence: 0.95 }, { 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: 'challenge', code: 'verification_interstitial', confidence: 0.8 },
{ pattern: 'attention required', code: 'verification_interstitial', confidence: 0.96 }, { pattern: 'attention required', code: 'verification_interstitial', confidence: 0.96 },
{ pattern: 'just a moment', code: 'verification_interstitial', confidence: 0.95 }, { 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: 'checking your browser', code: 'verification_interstitial', confidence: 0.97 },
{ pattern: 'access denied', code: 'access_gate', confidence: 0.86 }, { 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: 'verification_interstitial', confidence: 0.88 }, { pattern: '验证', code: 'verification_interstitial', confidence: 0.88 },
{ pattern: '人机验证', code: 'captcha_interstitial', confidence: 0.95 }, { 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[] = []; const signals: RiskSignal[] = [];
for (const item of urlPatterns) { for (const item of urlPatterns) {
if (lowerUrl.includes(item.pattern)) { 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); return mergeRiskSignals([], signals);
} }
@@ -1152,6 +1257,11 @@ async function handleTabNew(
if (command.url) { if (command.url) {
const page = browser.getPage(); const page = browser.getPage();
await page.goto(command.url, { waitUntil: 'domcontentloaded' }); 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); return successResponse(command.id, result);
+74
View File
@@ -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)', () => { describe('stale session recovery (all pages closed)', () => {
it('should recover when all pages are closed externally', async () => { it('should recover when all pages are closed externally', async () => {
const testBrowser = new BrowserManager(); const testBrowser = new BrowserManager();
+215 -155
View File
@@ -16,15 +16,7 @@ import {
} from 'playwright-core'; } from 'playwright-core';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { writeFile, mkdir } from 'node:fs/promises'; import { writeFile, mkdir } from 'node:fs/promises';
import type { LaunchCommand, TraceEvent } from './types.js'; import type { LaunchCommand, TraceEvent } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.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 IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
const DEFAULT_TAB_GROUP_NAME = 'Agent Browser Stealth'; 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 * Manages the Playwright browser lifecycle with multiple tabs/windows
@@ -172,7 +176,9 @@ export class BrowserManager {
private contextUserAgent: string | undefined = undefined; private contextUserAgent: string | undefined = undefined;
private downloadPath: string | null = null; private downloadPath: string | null = null;
private allowedDomains: string[] = []; private allowedDomains: string[] = [];
private tabGroupExtensionDir: string | null = null; private tabGroupIntent: TabGroupIntent | null = null;
private tabGroupCapabilityBySession: Map<string, TabGroupPluginAvailability> = new Map();
private tabGroupInFlight: WeakSet<Page> = new WeakSet();
/** /**
* Set the persistent color scheme preference. * Set the persistent color scheme preference.
@@ -496,115 +502,202 @@ export class BrowserManager {
return trimmed.slice(0, 80); return trimmed.slice(0, 80);
} }
/** private normalizeTabGroupPluginId(pluginId?: string): string | undefined {
* Build a temporary MV3 extension that auto-groups managed tabs under a fixed title. if (!pluginId) return undefined;
* This is only used for local Chromium launches. const trimmed = pluginId.trim();
*/ if (!trimmed) return undefined;
private createTabGroupExtension(groupTitle: string): string { return trimmed.slice(0, 128);
this.cleanupTabGroupExtension(); }
const extensionDir = mkdtempSync(path.join(os.tmpdir(), 'agent-browser-tab-group-')); private getAgentSessionName(): string {
const manifest = { const session = process.env.AGENT_BROWSER_SESSION?.trim();
manifest_version: 3, return session && session.length > 0 ? session : 'default';
name: 'Agent Browser Tab Grouper', }
version: '1.0.0',
permissions: ['tabs', 'tabGroups'], private buildSessionTabGroupTitle(baseTitle: string, session: string): string {
host_permissions: ['<all_urls>'], const normalizedBase = this.normalizeTabGroupName(baseTitle) ?? DEFAULT_TAB_GROUP_NAME;
background: { if (session === 'default') {
service_worker: 'service-worker.js', 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<string, unknown> | 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: [ {
{ requestType: TAB_GROUP_REQUEST_MESSAGE_TYPE,
matches: ['<all_urls>'], responseType: TAB_GROUP_RESPONSE_MESSAGE_TYPE,
js: ['content-script.js'], nonce,
run_at: 'document_start', session: intent.session,
match_about_blank: true, groupTitle: intent.groupTitle,
}, pluginId: intent.pluginId,
], timeoutMs: TAB_GROUP_REQUEST_TIMEOUT_MS,
};
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;
} }
} 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<void> {
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) { triggerTabGroupingForActivePage(source: string = 'active-page'): void {
await chrome.tabGroups.update(groupId, { if (!this.tabGroupIntent || this.pages.length === 0) return;
title: GROUP_TITLE, const page = this.getPage();
color: 'blue', this.scheduleTabGrouping(page, source);
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;
} }
// CDP profiling state // CDP profiling state
@@ -1719,9 +1812,6 @@ chrome.runtime.onMessage.addListener((message, sender) => {
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined); const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
const configuredExtensions = options.extensions ? [...options.extensions] : []; const configuredExtensions = options.extensions ? [...options.extensions] : [];
const hasStorageState = !!options.storageState; 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) { if (configuredExtensions.length > 0 && cdpEndpoint) {
throw new Error('Extensions cannot be used with CDP connection'); 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.contextTimezoneId = this.resolveStealthTimezoneId();
this.contextHeaders = undefined; this.contextHeaders = undefined;
this.contextUserAgent = options.userAgent; this.contextUserAgent = options.userAgent;
this.configureTabGroupIntent(options);
// -p flag takes precedence over AGENT_BROWSER_PROVIDER. // -p flag takes precedence over AGENT_BROWSER_PROVIDER.
const provider = options.provider ?? process.env.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.stealthConnectionKind = 'local';
} }
this.logStealthPolicy('launch policy', options.browser ?? 'chromium'); this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
const hasExtensions = configuredExtensions.length > 0;
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;
if (options.downloadPath) { if (options.downloadPath) {
this.downloadPath = options.downloadPath; this.downloadPath = options.downloadPath;
@@ -1953,7 +2009,7 @@ chrome.runtime.onMessage.addListener((message, sender) => {
let context: BrowserContext; let context: BrowserContext;
if (hasExtensions) { if (hasExtensions) {
// Extensions require persistent context in a temp directory // Extensions require persistent context in a temp directory
const extPaths = effectiveExtensions.join(','); const extPaths = configuredExtensions.join(',');
const session = process.env.AGENT_BROWSER_SESSION || 'default'; const session = process.env.AGENT_BROWSER_SESSION || 'default';
// Combine extension args with custom args and file access args // Combine extension args with custom args and file access args
const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`]; 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 // Invalidate CDP session since the active page changed
this.invalidateCDPSession().catch(() => {}); this.invalidateCDPSession().catch(() => {});
} }
this.scheduleTabGrouping(page, 'context-page');
}); });
} }
@@ -2533,6 +2591,7 @@ chrome.runtime.onMessage.addListener((message, sender) => {
this.setupPageTracking(page); this.setupPageTracking(page);
} }
this.activePageIndex = this.pages.length - 1; this.activePageIndex = this.pages.length - 1;
this.scheduleTabGrouping(page, 'new-tab');
return { index: this.activePageIndex, total: this.pages.length }; return { index: this.activePageIndex, total: this.pages.length };
} }
@@ -3285,8 +3344,6 @@ chrome.runtime.onMessage.addListener((message, sender) => {
} }
} }
this.cleanupTabGroupExtension();
this.pages = []; this.pages = [];
this.contexts = []; this.contexts = [];
this.cdpEndpoint = null; this.cdpEndpoint = null;
@@ -3305,6 +3362,9 @@ chrome.runtime.onMessage.addListener((message, sender) => {
this.contextTimezoneId = undefined; this.contextTimezoneId = undefined;
this.contextHeaders = undefined; this.contextHeaders = undefined;
this.contextUserAgent = undefined; this.contextUserAgent = undefined;
this.tabGroupIntent = null;
this.tabGroupCapabilityBySession.clear();
this.tabGroupInFlight = new WeakSet();
this.refMap = {}; this.refMap = {};
this.lastSnapshot = ''; this.lastSnapshot = '';
this.frameCallback = null; this.frameCallback = null;
+31 -44
View File
@@ -465,6 +465,7 @@ export async function startDaemon(options?: {
? colorSchemeEnv ? colorSchemeEnv
: undefined; : undefined;
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim(); const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
const launchOptions = { const launchOptions = {
id: 'auto', id: 'auto',
action: 'launch' as const, action: 'launch' as const,
@@ -480,53 +481,42 @@ export async function startDaemon(options?: {
colorScheme, colorScheme,
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined, tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
tabGroupPluginId:
tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined,
autoStateFilePath: getSessionAutoStatePath(), autoStateFilePath: getSessionAutoStatePath(),
}; };
let attachedToExistingBrowser = false; let attachedToExistingBrowser = false;
if (launchOptions.tabGroup) { try {
try { // Keep default CDP attempt minimal. Launch-only options like extensions
await manager.launch(launchOptions); // are incompatible with CDP and can cause false-negative attach failures.
attachedToExistingBrowser = true; const cdpLaunchOptions = {
if (process.env.AGENT_BROWSER_DEBUG === '1') { id: launchOptions.id,
console.error('[DEBUG] Auto-launch started local Chromium with --tab-group'); action: launchOptions.action,
} cdpPort: 9333,
} catch (error) { ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
if (process.env.AGENT_BROWSER_DEBUG === '1') { colorScheme: launchOptions.colorScheme,
const message = error instanceof Error ? error.message : String(error); userAgent: launchOptions.userAgent,
console.error(`[DEBUG] Local launch with --tab-group failed: ${message}`); 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 { } catch (error) {
try { if (process.env.AGENT_BROWSER_DEBUG === '1') {
// Keep default CDP attempt minimal. Launch-only options like extensions const message = error instanceof Error ? error.message : String(error);
// are incompatible with CDP and can cause false-negative attach failures. console.error(
const cdpLaunchOptions = { `[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
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}`
);
}
} }
} }
if (!attachedToExistingBrowser && !launchOptions.tabGroup) { if (!attachedToExistingBrowser) {
try { try {
await manager.launch({ await manager.launch({
id: launchOptions.id, id: launchOptions.id,
@@ -535,6 +525,8 @@ export async function startDaemon(options?: {
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme, colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent, userAgent: launchOptions.userAgent,
tabGroup: launchOptions.tabGroup,
tabGroupPluginId: launchOptions.tabGroupPluginId,
}); });
attachedToExistingBrowser = true; attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') { if (process.env.AGENT_BROWSER_DEBUG === '1') {
@@ -549,11 +541,6 @@ export async function startDaemon(options?: {
} }
if (!attachedToExistingBrowser) { 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( 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 your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
); );
+1
View File
@@ -52,6 +52,7 @@ const launchSchema = baseCommandSchema.extend({
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(), colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
downloadPath: z.string().optional(), downloadPath: z.string().optional(),
tabGroup: z.string().min(1).optional(), tabGroup: z.string().min(1).optional(),
tabGroupPluginId: z.string().min(1).optional(),
storageState: z.string().optional(), storageState: z.string().optional(),
allowedDomains: z.array(z.string()).optional(), allowedDomains: z.array(z.string()).optional(),
actionPolicy: z.string().optional(), actionPolicy: z.string().optional(),
+2 -1
View File
@@ -41,7 +41,8 @@ export interface LaunchCommand extends BaseCommand {
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath) 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[]; allowedDomains?: string[];
actionPolicy?: string; actionPolicy?: string;
confirmActions?: string[]; confirmActions?: string[];