feat: auto-group agent tabs by default in local Chromium
This commit is contained in:
@@ -50,6 +50,21 @@ agent-browser snapshot -i
|
|||||||
agent-browser click @e2
|
agent-browser click @e2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Default: Auto Group Agent Tabs (Local Chromium)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser open https://example.com
|
||||||
|
# Local Chromium launch auto-groups tabs under "Agent Browser Stealth"
|
||||||
|
|
||||||
|
# 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`.
|
||||||
|
|
||||||
## Stealth Architecture
|
## Stealth Architecture
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -2054,7 +2054,9 @@ mod tests {
|
|||||||
annotate: false,
|
annotate: false,
|
||||||
color_scheme: None,
|
color_scheme: None,
|
||||||
download_path: None,
|
download_path: None,
|
||||||
|
tab_group: None,
|
||||||
risk_mode: None,
|
risk_mode: None,
|
||||||
|
cli_tab_group: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ pub fn ensure_daemon(
|
|||||||
session_name: Option<&str>,
|
session_name: Option<&str>,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
download_path: Option<&str>,
|
download_path: Option<&str>,
|
||||||
|
tab_group: 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) {
|
||||||
@@ -374,6 +375,9 @@ pub fn ensure_daemon(
|
|||||||
if let Some(dp) = download_path {
|
if let Some(dp) = download_path {
|
||||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||||
}
|
}
|
||||||
|
if let Some(tg) = tab_group {
|
||||||
|
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
|
||||||
|
}
|
||||||
|
|
||||||
// Create new process group and session to fully detach
|
// Create new process group and session to fully detach
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -461,6 +465,9 @@ pub fn ensure_daemon(
|
|||||||
if let Some(dp) = download_path {
|
if let Some(dp) = download_path {
|
||||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||||
}
|
}
|
||||||
|
if let Some(tg) = tab_group {
|
||||||
|
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
|
||||||
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub struct Config {
|
|||||||
pub annotate: Option<bool>,
|
pub annotate: Option<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 risk_mode: Option<String>,
|
pub risk_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ impl Config {
|
|||||||
annotate: other.annotate.or(self.annotate),
|
annotate: other.annotate.or(self.annotate),
|
||||||
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),
|
||||||
risk_mode: other.risk_mode.or(self.risk_mode),
|
risk_mode: other.risk_mode.or(self.risk_mode),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +138,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
|||||||
"--color-scheme",
|
"--color-scheme",
|
||||||
"--channel",
|
"--channel",
|
||||||
"--download-path",
|
"--download-path",
|
||||||
|
"--tab-group",
|
||||||
"--risk-mode",
|
"--risk-mode",
|
||||||
];
|
];
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -207,6 +210,7 @@ pub struct Flags {
|
|||||||
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>,
|
||||||
/// 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>,
|
||||||
@@ -223,6 +227,7 @@ pub struct Flags {
|
|||||||
pub cli_allow_file_access: bool,
|
pub cli_allow_file_access: bool,
|
||||||
pub cli_annotate: bool,
|
pub cli_annotate: bool,
|
||||||
pub cli_download_path: bool,
|
pub cli_download_path: bool,
|
||||||
|
pub cli_tab_group: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_flags(args: &[String]) -> Flags {
|
pub fn parse_flags(args: &[String]) -> Flags {
|
||||||
@@ -291,6 +296,7 @@ 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),
|
||||||
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)
|
||||||
@@ -305,6 +311,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
cli_allow_file_access: false,
|
cli_allow_file_access: false,
|
||||||
cli_annotate: false,
|
cli_annotate: false,
|
||||||
cli_download_path: false,
|
cli_download_path: false,
|
||||||
|
cli_tab_group: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -466,6 +473,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--tab-group" => {
|
||||||
|
if let Some(s) = args.get(i + 1) {
|
||||||
|
flags.tab_group = Some(s.clone());
|
||||||
|
flags.cli_tab_group = 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());
|
||||||
@@ -516,6 +530,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
"--session-name",
|
"--session-name",
|
||||||
"--color-scheme",
|
"--color-scheme",
|
||||||
"--download-path",
|
"--download-path",
|
||||||
|
"--tab-group",
|
||||||
"--risk-mode",
|
"--risk-mode",
|
||||||
"--config",
|
"--config",
|
||||||
];
|
];
|
||||||
@@ -714,6 +729,24 @@ mod tests {
|
|||||||
assert!(!flags.cli_download_path);
|
assert!(!flags.cli_download_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_tab_group_flag() {
|
||||||
|
let input = vec![
|
||||||
|
"--tab-group".to_string(),
|
||||||
|
"Agent Browser Stealth".to_string(),
|
||||||
|
"snapshot".to_string(),
|
||||||
|
];
|
||||||
|
let flags = parse_flags(&input);
|
||||||
|
assert_eq!(flags.tab_group.as_deref(), Some("Agent Browser Stealth"));
|
||||||
|
assert!(flags.cli_tab_group);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_args_removes_tab_group() {
|
||||||
|
let cleaned = clean_args(&args("--tab-group AgentGroup open example.com"));
|
||||||
|
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[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"));
|
||||||
@@ -762,6 +795,7 @@ mod tests {
|
|||||||
"cdp": "9222",
|
"cdp": "9222",
|
||||||
"autoConnect": true,
|
"autoConnect": true,
|
||||||
"headers": "{\"Auth\":\"token\"}",
|
"headers": "{\"Auth\":\"token\"}",
|
||||||
|
"tabGroup": "Agent Browser Stealth",
|
||||||
"riskMode": "block"
|
"riskMode": "block"
|
||||||
}"#;
|
}"#;
|
||||||
let config: Config = serde_json::from_str(json).unwrap();
|
let config: Config = serde_json::from_str(json).unwrap();
|
||||||
@@ -788,6 +822,7 @@ mod tests {
|
|||||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||||
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.risk_mode.as_deref(), Some("block"));
|
assert_eq!(config.risk_mode.as_deref(), Some("block"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -287,6 +287,7 @@ fn main() {
|
|||||||
flags.session_name.as_deref(),
|
flags.session_name.as_deref(),
|
||||||
flags.debug,
|
flags.debug,
|
||||||
flags.download_path.as_deref(),
|
flags.download_path.as_deref(),
|
||||||
|
flags.tab_group.as_deref(),
|
||||||
) {
|
) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -338,6 +339,7 @@ fn main() {
|
|||||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||||
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"),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -424,6 +426,9 @@ fn main() {
|
|||||||
if let Some(ref dp) = flags.download_path {
|
if let Some(ref dp) = flags.download_path {
|
||||||
launch_cmd["downloadPath"] = json!(dp);
|
launch_cmd["downloadPath"] = json!(dp);
|
||||||
}
|
}
|
||||||
|
if let Some(ref tg) = flags.tab_group {
|
||||||
|
launch_cmd["tabGroup"] = json!(tg);
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -516,6 +521,9 @@ fn main() {
|
|||||||
if let Some(ref dp) = flags.download_path {
|
if let Some(ref dp) = flags.download_path {
|
||||||
launch_cmd["downloadPath"] = json!(dp);
|
launch_cmd["downloadPath"] = json!(dp);
|
||||||
}
|
}
|
||||||
|
if let Some(ref tg) = flags.tab_group {
|
||||||
|
launch_cmd["tabGroup"] = json!(tg);
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -549,6 +557,9 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
match send_command(launch_cmd, &flags.session) {
|
match send_command(launch_cmd, &flags.session) {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
@@ -589,7 +600,8 @@ 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!({
|
||||||
@@ -643,7 +655,8 @@ fn main() {
|
|||||||
|| flags.allow_file_access
|
|| flags.allow_file_access
|
||||||
|| 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
|
||||||
@@ -708,6 +721,9 @@ fn main() {
|
|||||||
if let Some(ref dp) = flags.download_path {
|
if let Some(ref dp) = flags.download_path {
|
||||||
launch_cmd["downloadPath"] = json!(dp);
|
launch_cmd["downloadPath"] = json!(dp);
|
||||||
}
|
}
|
||||||
|
if let Some(ref tg) = flags.tab_group {
|
||||||
|
launch_cmd["tabGroup"] = json!(tg);
|
||||||
|
}
|
||||||
|
|
||||||
match send_command(launch_cmd, &flags.session) {
|
match send_command(launch_cmd, &flags.session) {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
|
|||||||
@@ -2409,6 +2409,7 @@ 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)
|
||||||
--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 (cookies, localStorage)
|
||||||
--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)
|
||||||
@@ -2467,6 +2468,7 @@ 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_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
|
||||||
|
|||||||
@@ -130,6 +130,22 @@ agent-browser wait --download [path] # Wait for any download to complete
|
|||||||
|
|
||||||
Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes.
|
Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes.
|
||||||
|
|
||||||
|
## Tab grouping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser open https://example.com
|
||||||
|
# Local Chromium launch auto-groups tabs under "Agent Browser Stealth"
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Mouse
|
## Mouse
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -280,6 +296,7 @@ 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)
|
||||||
--debug # Debug output (includes stealth connection type + capabilities)
|
--debug # Debug output (includes stealth connection type + capabilities)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
|||||||
</td>
|
</td>
|
||||||
<td>string</td>
|
<td>string</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>tabGroup</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>--tab-group</code>
|
||||||
|
</td>
|
||||||
|
<td>string (override default tab group title; Chromium local launch only)</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<code>riskMode</code>
|
<code>riskMode</code>
|
||||||
@@ -406,6 +415,13 @@ These environment variables configure additional daemon and runtime behavior:
|
|||||||
<td>Default directory for browser downloads.</td>
|
<td>Default directory for browser downloads.</td>
|
||||||
<td>(temp directory)</td>
|
<td>(temp directory)</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>AGENT_BROWSER_TAB_GROUP</code>
|
||||||
|
</td>
|
||||||
|
<td>Override default auto-group title for agent tabs (Chromium local launch only).</td>
|
||||||
|
<td>(disabled)</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<code>AGENT_BROWSER_RISK_MODE</code>
|
<code>AGENT_BROWSER_RISK_MODE</code>
|
||||||
|
|||||||
@@ -89,6 +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)
|
||||||
|
|
||||||
# Capture
|
# Capture
|
||||||
agent-browser screenshot # Screenshot to temp dir
|
agent-browser screenshot # Screenshot to temp dir
|
||||||
@@ -247,6 +248,25 @@ AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com
|
|||||||
agent-browser set media dark
|
agent-browser set media dark
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Tab Grouping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local Chromium launch auto-groups under "Agent Browser Stealth"
|
||||||
|
agent-browser open https://example.com
|
||||||
|
|
||||||
|
# Override the default group title
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Visual Browser (Debugging)
|
### Visual Browser (Debugging)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+175
-5
@@ -16,7 +16,15 @@ 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 { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
import {
|
||||||
|
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';
|
||||||
@@ -127,6 +135,7 @@ 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||||
@@ -163,6 +172,7 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the persistent color scheme preference.
|
* Set the persistent color scheme preference.
|
||||||
@@ -478,6 +488,125 @@ export class BrowserManager {
|
|||||||
return warnings;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeTabGroupName(name?: string): string | undefined {
|
||||||
|
if (!name) return undefined;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
// Keep the title short for stable UI rendering in Chrome's tab strip.
|
||||||
|
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();
|
||||||
|
|
||||||
|
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: ['<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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
// CDP profiling state
|
// CDP profiling state
|
||||||
private static readonly MAX_PROFILE_EVENTS = 5_000_000;
|
private static readonly MAX_PROFILE_EVENTS = 5_000_000;
|
||||||
private profilingActive: boolean = false;
|
private profilingActive: boolean = false;
|
||||||
@@ -1588,14 +1717,17 @@ export class BrowserManager {
|
|||||||
async launch(options: LaunchCommand): Promise<void> {
|
async launch(options: LaunchCommand): Promise<void> {
|
||||||
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
||||||
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
||||||
const hasExtensions = !!options.extensions?.length;
|
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 (hasExtensions && 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasStorageState && hasExtensions) {
|
if (hasStorageState && configuredExtensions.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Storage state cannot be used with extensions (extensions require persistent context)'
|
'Storage state cannot be used with extensions (extensions require persistent context)'
|
||||||
);
|
);
|
||||||
@@ -1646,6 +1778,42 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
|
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;
|
||||||
|
|
||||||
if (options.downloadPath) {
|
if (options.downloadPath) {
|
||||||
this.downloadPath = options.downloadPath;
|
this.downloadPath = options.downloadPath;
|
||||||
}
|
}
|
||||||
@@ -1785,7 +1953,7 @@ export class BrowserManager {
|
|||||||
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 = options.extensions!.join(',');
|
const extPaths = effectiveExtensions.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}`];
|
||||||
@@ -3117,6 +3285,8 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.cleanupTabGroupExtension();
|
||||||
|
|
||||||
this.pages = [];
|
this.pages = [];
|
||||||
this.contexts = [];
|
this.contexts = [];
|
||||||
this.cdpEndpoint = null;
|
this.cdpEndpoint = null;
|
||||||
|
|||||||
+23
-1
@@ -464,6 +464,7 @@ export async function startDaemon(options?: {
|
|||||||
colorSchemeEnv === 'no-preference'
|
colorSchemeEnv === 'no-preference'
|
||||||
? colorSchemeEnv
|
? colorSchemeEnv
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
|
||||||
const launchOptions = {
|
const launchOptions = {
|
||||||
id: 'auto',
|
id: 'auto',
|
||||||
action: 'launch' as const,
|
action: 'launch' as const,
|
||||||
@@ -478,10 +479,25 @@ export async function startDaemon(options?: {
|
|||||||
allowFileAccess: allowFileAccess,
|
allowFileAccess: allowFileAccess,
|
||||||
|
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
|
||||||
autoStateFilePath: getSessionAutoStatePath(),
|
autoStateFilePath: getSessionAutoStatePath(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let attachedToExistingBrowser = false;
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
// Keep default CDP attempt minimal. Launch-only options like extensions
|
// Keep default CDP attempt minimal. Launch-only options like extensions
|
||||||
// are incompatible with CDP and can cause false-negative attach failures.
|
// are incompatible with CDP and can cause false-negative attach failures.
|
||||||
@@ -508,8 +524,9 @@ export async function startDaemon(options?: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!attachedToExistingBrowser) {
|
if (!attachedToExistingBrowser && !launchOptions.tabGroup) {
|
||||||
try {
|
try {
|
||||||
await manager.launch({
|
await manager.launch({
|
||||||
id: launchOptions.id,
|
id: launchOptions.id,
|
||||||
@@ -532,6 +549,11 @@ 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.'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ describe('parseCommand', () => {
|
|||||||
expect((result.command as any).stealth).toBeUndefined();
|
expect((result.command as any).stealth).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should parse launch command with tabGroup', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
cmd({ id: '1', action: 'launch', headless: false, tabGroup: 'Agent Browser Stealth' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.command.action).toBe('launch');
|
||||||
|
expect(result.command.tabGroup).toBe('Agent Browser Stealth');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('navigation', () => {
|
describe('navigation', () => {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const launchSchema = baseCommandSchema.extend({
|
|||||||
allowFileAccess: z.boolean().optional(),
|
allowFileAccess: z.boolean().optional(),
|
||||||
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(),
|
||||||
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(),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ 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
|
||||||
allowedDomains?: string[];
|
allowedDomains?: string[];
|
||||||
actionPolicy?: string;
|
actionPolicy?: string;
|
||||||
confirmActions?: string[];
|
confirmActions?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user