feat: add doctor diagnostics and bump to 0.15.2-fork.1
This commit is contained in:
@@ -242,6 +242,21 @@ node scripts/check-sannysoft-webdriver.js --binary ./cli/target/release/agent-br
|
||||
node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser
|
||||
```
|
||||
|
||||
## Doctor Diagnostics
|
||||
|
||||
Use `doctor` to quickly diagnose local CDP and tab-group plugin readiness:
|
||||
|
||||
```bash
|
||||
agent-browser doctor
|
||||
agent-browser --json doctor
|
||||
```
|
||||
|
||||
`doctor` checks:
|
||||
|
||||
- CDP probe status (preferred `:9333` plus common ports)
|
||||
- DevToolsActivePort discovery from local Chrome profiles
|
||||
- Tab-group extension handshake (when currently attached in CDP mode)
|
||||
|
||||
## Upstream Compatibility
|
||||
|
||||
This fork intentionally keeps command workflows close to upstream while concentrating custom behavior in stealth, policy, and anti-detection handling.
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.15.2-fork.0"
|
||||
version = "0.15.2-fork.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"dirs",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.15.2-fork.0"
|
||||
version = "0.15.2-fork.1"
|
||||
edition = "2021"
|
||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+32
-21
@@ -655,6 +655,17 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
// === Close ===
|
||||
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
||||
|
||||
// === Doctor ===
|
||||
"doctor" => {
|
||||
if !rest.is_empty() {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("doctor does not accept arguments: {}", rest.join(" ")),
|
||||
usage: "doctor",
|
||||
});
|
||||
}
|
||||
Ok(json!({ "id": id, "action": "doctor" }))
|
||||
}
|
||||
|
||||
// === Connect (CDP) ===
|
||||
"connect" => {
|
||||
let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -2376,16 +2387,12 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "navigate");
|
||||
assert_eq!(
|
||||
cmd["url"],
|
||||
"chrome-extension://abcdefghijklmnop/popup.html"
|
||||
);
|
||||
assert_eq!(cmd["url"], "chrome-extension://abcdefghijklmnop/popup.html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_chrome_url() {
|
||||
let cmd =
|
||||
parse_command(&args("open chrome://extensions"), &default_flags()).unwrap();
|
||||
let cmd = parse_command(&args("open chrome://extensions"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "navigate");
|
||||
assert_eq!(cmd["url"], "chrome://extensions");
|
||||
}
|
||||
@@ -2963,6 +2970,21 @@ mod tests {
|
||||
assert!(err.format().contains("Invalid base64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doctor() {
|
||||
let cmd = parse_command(&args("doctor"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "doctor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doctor_rejects_arguments() {
|
||||
let result = parse_command(&args("doctor extra"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, ParseError::InvalidValue { .. }));
|
||||
assert!(err.format().contains("doctor does not accept arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_command() {
|
||||
let result = parse_command(&args("unknowncommand"), &default_flags());
|
||||
@@ -3717,11 +3739,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scroll_with_selector_short_flag() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll left 100 -s .sidebar"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd = parse_command(&args("scroll left 100 -s .sidebar"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "left");
|
||||
assert_eq!(cmd["amount"], 100);
|
||||
@@ -3730,11 +3748,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_before_positional() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll --selector .panel down 400"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd =
|
||||
parse_command(&args("scroll --selector .panel down 400"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 400);
|
||||
@@ -3743,11 +3758,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_only() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll --selector .content"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd = parse_command(&args("scroll --selector .content"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 300);
|
||||
|
||||
+216
-27
@@ -38,7 +38,9 @@ fn truncate_if_needed(content: &str, max: Option<usize>) -> String {
|
||||
let total_chars = content.chars().count();
|
||||
format!(
|
||||
"{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]",
|
||||
&content[..byte_offset], limit, total_chars
|
||||
&content[..byte_offset],
|
||||
limit,
|
||||
total_chars
|
||||
)
|
||||
}
|
||||
// Content has fewer than `limit` chars despite more bytes
|
||||
@@ -51,7 +53,10 @@ fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptio
|
||||
if opts.content_boundaries {
|
||||
let origin_str = origin.unwrap_or("unknown");
|
||||
let nonce = get_boundary_nonce();
|
||||
println!("--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---", nonce, origin_str);
|
||||
println!(
|
||||
"--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---",
|
||||
nonce, origin_str
|
||||
);
|
||||
println!("{}", content);
|
||||
println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce);
|
||||
} else {
|
||||
@@ -65,14 +70,18 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
let mut json_val = serde_json::to_value(resp).unwrap_or_default();
|
||||
if let Some(obj) = json_val.as_object_mut() {
|
||||
let nonce = get_boundary_nonce();
|
||||
let origin = obj.get("data")
|
||||
let origin = obj
|
||||
.get("data")
|
||||
.and_then(|d| d.get("origin"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
obj.insert("_boundary".to_string(), serde_json::json!({
|
||||
"nonce": nonce,
|
||||
"origin": origin,
|
||||
}));
|
||||
obj.insert(
|
||||
"_boundary".to_string(),
|
||||
serde_json::json!({
|
||||
"nonce": nonce,
|
||||
"origin": origin,
|
||||
}),
|
||||
);
|
||||
}
|
||||
println!("{}", serde_json::to_string(&json_val).unwrap_or_default());
|
||||
} else {
|
||||
@@ -105,12 +114,18 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown_risk");
|
||||
let source = signal.get("source").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let source = signal
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let evidence = signal
|
||||
.get("evidence")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
let confidence = signal.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let confidence = signal
|
||||
.get("confidence")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
println!(
|
||||
"{} risk-signal code={} source={} evidence={} confidence={:.2}",
|
||||
color::warning_indicator(),
|
||||
@@ -134,6 +149,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
// Diff responses -- route by action to avoid fragile shape probing
|
||||
if let Some(obj) = data.as_object() {
|
||||
match action {
|
||||
Some("doctor") => {
|
||||
print_doctor_report(obj);
|
||||
return;
|
||||
}
|
||||
Some("diff_snapshot") => {
|
||||
print_snapshot_diff(obj);
|
||||
return;
|
||||
@@ -295,7 +314,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
for log in logs {
|
||||
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
console_output.push_str(&format!("{} {}\n", color::console_level_prefix(level), text));
|
||||
console_output.push_str(&format!(
|
||||
"{} {}\n",
|
||||
color::console_level_prefix(level),
|
||||
text
|
||||
));
|
||||
}
|
||||
if console_output.ends_with('\n') {
|
||||
console_output.pop();
|
||||
@@ -697,7 +720,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = p.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let user = p.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(" {} {} {}", color::green(name), color::dim(user), color::dim(url));
|
||||
println!(
|
||||
" {} {} {}",
|
||||
color::green(name),
|
||||
color::dim(user),
|
||||
color::dim(url)
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -707,8 +735,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
if let Some(profile) = data.get("profile").and_then(|v| v.as_object()) {
|
||||
let name = profile.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = profile.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let user = profile.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let created = profile.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let user = profile
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let created = profile
|
||||
.get("createdAt")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str());
|
||||
println!("Name: {}", name);
|
||||
println!("URL: {}", url);
|
||||
@@ -723,47 +757,94 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
// Auth save/update/login/delete
|
||||
if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} Auth profile '{}' saved", color::success_indicator(), name);
|
||||
println!(
|
||||
"{} Auth profile '{}' saved",
|
||||
color::success_indicator(),
|
||||
name
|
||||
);
|
||||
return;
|
||||
}
|
||||
if data.get("updated").and_then(|v| v.as_bool()).unwrap_or(false)
|
||||
&& !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
if data
|
||||
.get("updated")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
&& !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false)
|
||||
{
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} Auth profile '{}' updated", color::success_indicator(), name);
|
||||
println!(
|
||||
"{} Auth profile '{}' updated",
|
||||
color::success_indicator(),
|
||||
name
|
||||
);
|
||||
return;
|
||||
}
|
||||
if data.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
if data
|
||||
.get("loggedIn")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||
println!("{} Logged in as '{}' - {}", color::success_indicator(), name, title);
|
||||
println!(
|
||||
"{} Logged in as '{}' - {}",
|
||||
color::success_indicator(),
|
||||
name,
|
||||
title
|
||||
);
|
||||
} else {
|
||||
println!("{} Logged in as '{}'", color::success_indicator(), name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
if data
|
||||
.get("deleted")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
|
||||
println!("{} Auth profile '{}' deleted", color::success_indicator(), name);
|
||||
println!(
|
||||
"{} Auth profile '{}' deleted",
|
||||
color::success_indicator(),
|
||||
name
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation required (for orchestrator use)
|
||||
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
if data
|
||||
.get("confirmation_required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let description = data.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let description = data
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let cid = data
|
||||
.get("confirmation_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
println!("Confirmation required:");
|
||||
println!(" {}: {}", category, description);
|
||||
println!(" Run: agent-browser confirm {}", cid);
|
||||
println!(" Or: agent-browser deny {}", cid);
|
||||
return;
|
||||
}
|
||||
if data.get("confirmed").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
if data
|
||||
.get("confirmed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
println!("{} Action confirmed", color::success_indicator());
|
||||
return;
|
||||
}
|
||||
if data.get("denied").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
if data
|
||||
.get("denied")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
println!("{} Action denied", color::success_indicator());
|
||||
return;
|
||||
}
|
||||
@@ -2149,6 +2230,31 @@ Examples:
|
||||
agent-browser click @e1
|
||||
"##
|
||||
}
|
||||
"doctor" => {
|
||||
r##"
|
||||
agent-browser doctor - Diagnose CDP and tab-group plugin health
|
||||
|
||||
Usage: agent-browser doctor
|
||||
|
||||
Runs a non-destructive health check focused on:
|
||||
- CDP endpoint reachability (preferred :9333 + common ports)
|
||||
- DevToolsActivePort discovery from local Chrome profiles
|
||||
- Tab-group plugin handshake status (when connected via CDP)
|
||||
|
||||
Notes:
|
||||
- doctor does not accept positional arguments
|
||||
- If browser is not already connected, doctor will still report CDP probe results
|
||||
- Plugin handshake requires a live CDP page and the extension to be installed
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser doctor
|
||||
agent-browser --json doctor
|
||||
"##
|
||||
}
|
||||
|
||||
// === iOS Commands ===
|
||||
"tap" => {
|
||||
@@ -2377,6 +2483,7 @@ Sessions:
|
||||
Setup:
|
||||
install Install browser binaries
|
||||
install --with-deps Also install system dependencies (Linux)
|
||||
doctor Diagnose CDP + plugin health
|
||||
|
||||
Snapshot Options:
|
||||
-i, --interactive Only interactive elements
|
||||
@@ -2536,6 +2643,85 @@ pub fn print_response(resp: &Response, json: bool, action: Option<&str>) {
|
||||
print_response_with_opts(resp, action, &opts);
|
||||
}
|
||||
|
||||
fn status_badge(status: &str) -> String {
|
||||
match status {
|
||||
"pass" => color::green("PASS"),
|
||||
"warn" => color::yellow("WARN"),
|
||||
"fail" => color::red("FAIL"),
|
||||
"skip" => color::dim("SKIP"),
|
||||
_ => status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_doctor_report(data: &serde_json::Map<String, serde_json::Value>) {
|
||||
let ok = data.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let summary = if ok {
|
||||
format!("{} doctor checks passed", color::success_indicator())
|
||||
} else {
|
||||
format!("{} doctor found issues", color::error_indicator())
|
||||
};
|
||||
println!("{}", summary);
|
||||
|
||||
if let Some(context) = data.get("context").and_then(|v| v.as_object()) {
|
||||
let launched = context
|
||||
.get("launched")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let connection = context
|
||||
.get("connectionKind")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let session = context
|
||||
.get("session")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("default");
|
||||
let cdp_endpoint = context
|
||||
.get("cdpEndpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
println!(
|
||||
" context: launched={} connection={} session={} cdp={}",
|
||||
launched, connection, session, cdp_endpoint
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(checks) = data.get("checks").and_then(|v| v.as_array()) {
|
||||
for check in checks {
|
||||
let Some(obj) = check.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let name = obj
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let status = obj
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let message = obj.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(" [{}] {} - {}", status_badge(status), name, message);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(plugin) = data.get("plugin").and_then(|v| v.as_object()) {
|
||||
let plugin_status = plugin
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let plugin_message = plugin.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let plugin_id = plugin
|
||||
.get("configuredPluginId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
println!(
|
||||
" plugin: [{}] id={} {}",
|
||||
status_badge(plugin_status),
|
||||
plugin_id,
|
||||
plugin_message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_snapshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
||||
let changed = data
|
||||
.get("changed")
|
||||
@@ -2627,7 +2813,10 @@ fn parse_fork_version(version: &str) -> Option<(&str, &str)> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !fork.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
|
||||
if !fork
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((upstream, fork))
|
||||
|
||||
@@ -33,6 +33,7 @@ agent-browser pdf <path> # Save page as PDF
|
||||
agent-browser snapshot # Accessibility tree with refs
|
||||
agent-browser eval <js> # Run JavaScript
|
||||
agent-browser connect <port|url> # Connect to browser via CDP
|
||||
agent-browser doctor # Diagnose CDP + tab-group plugin health
|
||||
agent-browser --version # Show CLI version
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
```
|
||||
@@ -252,6 +253,7 @@ agent-browser console --clear # Clear console log
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors --clear # Clear error log
|
||||
agent-browser highlight <sel> # Highlight element
|
||||
agent-browser doctor # Diagnose CDP + plugin handshake status
|
||||
```
|
||||
|
||||
## State management
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.15.2-fork.0",
|
||||
"version": "0.15.2-fork.1",
|
||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
@@ -34,6 +34,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
|
||||
"check:daemon-pid-recovery": "node scripts/check-daemon-pid-recovery.js",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"verify:native-version": "node scripts/verify-native-version.js",
|
||||
"clawhub:sync": "bash scripts/clawhub-sync.sh",
|
||||
|
||||
@@ -52,6 +52,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
|
||||
# Navigation
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser --risk-mode block open <url> # Block if verification/captcha interstitial is detected
|
||||
agent-browser doctor # Diagnose CDP + tab-group plugin health
|
||||
agent-browser close # Close browser
|
||||
agent-browser --version # Show CLI version (fork builds include upstream/fork)
|
||||
|
||||
@@ -234,6 +235,9 @@ agent-browser --cdp 9222 snapshot
|
||||
|
||||
# Debug auto-attach behavior
|
||||
agent-browser --debug snapshot
|
||||
|
||||
# Diagnose CDP + plugin handshake status
|
||||
agent-browser doctor
|
||||
```
|
||||
|
||||
### Color Scheme (Dark Mode)
|
||||
|
||||
@@ -133,6 +133,7 @@ import type {
|
||||
DiffScreenshotData,
|
||||
DiffUrlData,
|
||||
ContentData,
|
||||
DoctorCommand,
|
||||
TabListData,
|
||||
TabNewData,
|
||||
TabSwitchData,
|
||||
@@ -289,6 +290,8 @@ export async function executeCommand(command: Command, browser: BrowserManager):
|
||||
return await handleContent(command, browser);
|
||||
case 'close':
|
||||
return await handleClose(command, browser);
|
||||
case 'doctor':
|
||||
return await handleDoctor(command, browser);
|
||||
case 'tab_new':
|
||||
return await handleTabNew(command, browser);
|
||||
case 'tab_list':
|
||||
@@ -524,6 +527,11 @@ async function handleLaunch(
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDoctor(command: DoctorCommand, browser: BrowserManager): Promise<Response> {
|
||||
const report = await browser.runDoctor();
|
||||
return successResponse(command.id, report);
|
||||
}
|
||||
|
||||
async function handleNavigate(
|
||||
command: NavigateCommand,
|
||||
browser: BrowserManager
|
||||
|
||||
+295
-8
@@ -18,7 +18,13 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||
import type {
|
||||
DoctorCheck,
|
||||
DoctorCheckStatus,
|
||||
DoctorData,
|
||||
LaunchCommand,
|
||||
TraceEvent,
|
||||
} from './types.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
import { safeHeaderMerge } from './state-utils.js';
|
||||
import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
|
||||
@@ -175,6 +181,7 @@ export class BrowserManager {
|
||||
private contextTimezoneId: string | undefined = undefined;
|
||||
private contextHeaders: Record<string, string> | undefined = undefined;
|
||||
private contextUserAgent: string | undefined = undefined;
|
||||
private allowWebGLContextFallback: boolean = false;
|
||||
private downloadPath: string | null = null;
|
||||
private allowedDomains: string[] = [];
|
||||
private tabGroupIntent: TabGroupIntent | null = null;
|
||||
@@ -468,10 +475,49 @@ export class BrowserManager {
|
||||
await applyStealthScripts(context, {
|
||||
...options,
|
||||
userAgent: this.contextUserAgent,
|
||||
allowWebGLContextFallback: this.allowWebGLContextFallback,
|
||||
});
|
||||
this.logStealthPolicy('init-script applied');
|
||||
}
|
||||
|
||||
private async probeNativeWebGL(page: Page): Promise<{ loose: boolean; strict: boolean } | null> {
|
||||
try {
|
||||
return await page.evaluate(() => {
|
||||
const doc = (globalThis as any).document;
|
||||
if (!doc || typeof doc.createElement !== 'function') return null;
|
||||
const canvas = doc.createElement('canvas');
|
||||
const strict =
|
||||
canvas.getContext('webgl', { failIfMajorPerformanceCaveat: true }) ||
|
||||
canvas.getContext('experimental-webgl', { failIfMajorPerformanceCaveat: true }) ||
|
||||
canvas.getContext('webgl2', { failIfMajorPerformanceCaveat: true });
|
||||
const loose =
|
||||
canvas.getContext('webgl') ||
|
||||
canvas.getContext('experimental-webgl') ||
|
||||
canvas.getContext('webgl2');
|
||||
return { strict: !!strict, loose: !!loose };
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async configureWebGLFallbackFromPage(page: Page, source: string): Promise<void> {
|
||||
const probe = await this.probeNativeWebGL(page);
|
||||
this.allowWebGLContextFallback = !!probe && probe.strict === false;
|
||||
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`[DEBUG] WebGL probe (${source}): strict=${String(probe?.strict)} loose=${String(probe?.loose)} fallback=${this.allowWebGLContextFallback}`
|
||||
);
|
||||
}
|
||||
|
||||
if (probe && probe.strict === false) {
|
||||
this.launchWarnings.push(
|
||||
`Strict WebGL context is unavailable on ${source} (often caused by GPU-disabled CDP browsers, e.g. --use-gl=disabled); enabling compatibility fallback context for fingerprint probes.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// CDP session for screencast and input injection
|
||||
private cdpSession: CDPSession | null = null;
|
||||
private screencastActive: boolean = false;
|
||||
@@ -606,6 +652,7 @@ export class BrowserManager {
|
||||
reason?: string;
|
||||
};
|
||||
} | null>((resolve) => {
|
||||
const win = globalThis as any;
|
||||
let settled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
@@ -624,15 +671,15 @@ export class BrowserManager {
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.removeEventListener('message', onMessage);
|
||||
win.removeEventListener('message', onMessage);
|
||||
if (typeof timer === 'number') {
|
||||
window.clearTimeout(timer);
|
||||
win.clearTimeout(timer);
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window) return;
|
||||
const onMessage = (event: any) => {
|
||||
if (event.source !== win) return;
|
||||
const data = event.data as Record<string, unknown> | null;
|
||||
if (!data || data.type !== responseType) return;
|
||||
if (data.nonce !== nonce) return;
|
||||
@@ -660,11 +707,11 @@ export class BrowserManager {
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
timer = window.setTimeout(() => finish(null), timeoutMs);
|
||||
win.addEventListener('message', onMessage);
|
||||
timer = win.setTimeout(() => finish(null), timeoutMs);
|
||||
|
||||
try {
|
||||
window.postMessage(
|
||||
win.postMessage(
|
||||
{
|
||||
type: requestType,
|
||||
nonce,
|
||||
@@ -1921,6 +1968,7 @@ export class BrowserManager {
|
||||
this.contextTimezoneId = this.resolveStealthTimezoneId();
|
||||
this.contextHeaders = undefined;
|
||||
this.contextUserAgent = options.userAgent;
|
||||
this.allowWebGLContextFallback = false;
|
||||
// -p flag takes precedence over AGENT_BROWSER_PROVIDER.
|
||||
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
||||
|
||||
@@ -2207,6 +2255,16 @@ export class BrowserManager {
|
||||
});
|
||||
}
|
||||
|
||||
let probePage = context.pages()[0];
|
||||
const createdProbePage = !probePage;
|
||||
if (!probePage) {
|
||||
probePage = await context.newPage();
|
||||
}
|
||||
await this.configureWebGLFallbackFromPage(probePage, 'local');
|
||||
if (createdProbePage) {
|
||||
await probePage.close().catch(() => {});
|
||||
}
|
||||
|
||||
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
@@ -2323,6 +2381,8 @@ export class BrowserManager {
|
||||
this.browser = browser;
|
||||
this.cdpEndpoint = cdpEndpoint;
|
||||
|
||||
await this.configureWebGLFallbackFromPage(allPages[0], 'cdp');
|
||||
|
||||
for (const context of contexts) {
|
||||
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||
context.setDefaultTimeout(10000);
|
||||
@@ -2572,6 +2632,233 @@ export class BrowserManager {
|
||||
throw new Error(`No running Chrome instance with remote debugging found.\n${hint}`);
|
||||
}
|
||||
|
||||
private addDoctorCheck(
|
||||
checks: DoctorCheck[],
|
||||
name: string,
|
||||
status: DoctorCheckStatus,
|
||||
message: string,
|
||||
details?: Record<string, unknown>
|
||||
): void {
|
||||
checks.push({ name, status, message, ...(details ? { details } : {}) });
|
||||
}
|
||||
|
||||
private buildDoctorTabGroupIntent(): TabGroupIntent {
|
||||
const session = this.getAgentSessionName();
|
||||
const pluginId =
|
||||
this.tabGroupIntent?.pluginId ??
|
||||
this.normalizeTabGroupPluginId(process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID) ??
|
||||
DEFAULT_TAB_GROUP_PLUGIN_ID;
|
||||
const groupTitle =
|
||||
this.tabGroupIntent?.groupTitle ??
|
||||
this.buildSessionTabGroupTitle(DEFAULT_TAB_GROUP_NAME, session);
|
||||
|
||||
return {
|
||||
session,
|
||||
groupTitle,
|
||||
pluginId,
|
||||
allowedDomains:
|
||||
(this.tabGroupIntent?.allowedDomains?.length ?? 0) > 0
|
||||
? [...(this.tabGroupIntent?.allowedDomains ?? [])]
|
||||
: [...this.allowedDomains],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run connection diagnostics for CDP discovery and tab-group plugin handshake.
|
||||
* This is intentionally side-effect-light: it does not navigate or force launch.
|
||||
*/
|
||||
async runDoctor(): Promise<DoctorData> {
|
||||
const checks: DoctorCheck[] = [];
|
||||
const preferredPort = 9333;
|
||||
const discovered: DoctorData['cdp']['discovered'] = [];
|
||||
const devToolsActivePort: DoctorData['cdp']['devToolsActivePort'] = [];
|
||||
const seenPorts = new Set<number>();
|
||||
|
||||
const pushDiscovery = (
|
||||
port: number,
|
||||
source: 'preferred-port' | 'common-port' | 'devtools-active-port',
|
||||
wsUrl: string | null,
|
||||
status: DoctorCheckStatus,
|
||||
note?: string
|
||||
) => {
|
||||
discovered.push({
|
||||
port,
|
||||
source,
|
||||
status,
|
||||
...(wsUrl ? { wsUrl } : {}),
|
||||
...(note ? { note } : {}),
|
||||
});
|
||||
seenPorts.add(port);
|
||||
};
|
||||
|
||||
const preferredWsUrl = await this.probeDebugPort(preferredPort);
|
||||
pushDiscovery(
|
||||
preferredPort,
|
||||
'preferred-port',
|
||||
preferredWsUrl,
|
||||
preferredWsUrl ? 'pass' : 'fail'
|
||||
);
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'cdp:preferred-9333',
|
||||
preferredWsUrl ? 'pass' : 'fail',
|
||||
preferredWsUrl
|
||||
? `CDP :${preferredPort} reachable`
|
||||
: `CDP :${preferredPort} is not reachable via http://127.0.0.1:${preferredPort}/json/version`
|
||||
);
|
||||
|
||||
for (const port of [9222, 9229]) {
|
||||
const wsUrl = await this.probeDebugPort(port);
|
||||
pushDiscovery(port, 'common-port', wsUrl, wsUrl ? 'pass' : 'fail');
|
||||
}
|
||||
|
||||
for (const userDataDir of this.getChromeUserDataDirs()) {
|
||||
const activePort = this.readDevToolsActivePort(userDataDir);
|
||||
if (!activePort) {
|
||||
devToolsActivePort.push({
|
||||
userDataDir,
|
||||
status: 'skip',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
devToolsActivePort.push({
|
||||
userDataDir,
|
||||
status: 'pass',
|
||||
port: activePort.port,
|
||||
wsPath: activePort.wsPath,
|
||||
});
|
||||
|
||||
if (!seenPorts.has(activePort.port)) {
|
||||
const wsUrl = await this.probeDebugPort(activePort.port);
|
||||
pushDiscovery(
|
||||
activePort.port,
|
||||
'devtools-active-port',
|
||||
wsUrl,
|
||||
wsUrl ? 'pass' : 'warn',
|
||||
wsUrl
|
||||
? 'resolved from DevToolsActivePort'
|
||||
: 'DevToolsActivePort exists, but /json/version is unavailable (likely WS-only debug server)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reachableEndpoints = discovered.filter((entry) => entry.status === 'pass');
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'cdp:any-reachable-endpoint',
|
||||
reachableEndpoints.length > 0 ? 'pass' : 'fail',
|
||||
reachableEndpoints.length > 0
|
||||
? `Found ${reachableEndpoints.length} reachable CDP endpoint(s)`
|
||||
: 'No reachable CDP endpoints found on preferred/common/local profile ports',
|
||||
{
|
||||
endpoints: discovered.map((entry) => ({
|
||||
port: entry.port,
|
||||
source: entry.source,
|
||||
status: entry.status,
|
||||
})),
|
||||
}
|
||||
);
|
||||
|
||||
const pluginIntent = this.buildDoctorTabGroupIntent();
|
||||
const pluginResult: DoctorData['plugin'] = {
|
||||
configuredPluginId: pluginIntent.pluginId,
|
||||
status: 'skip',
|
||||
mode: 'not-launched',
|
||||
message: 'Browser is not launched; plugin handshake skipped',
|
||||
};
|
||||
|
||||
if (!this.isLaunched()) {
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:tab-group-handshake',
|
||||
pluginResult.status,
|
||||
pluginResult.message,
|
||||
{ configuredPluginId: pluginIntent.pluginId }
|
||||
);
|
||||
} else if (this.stealthConnectionKind !== 'cdp') {
|
||||
pluginResult.mode = 'non-cdp';
|
||||
pluginResult.status = 'skip';
|
||||
pluginResult.message = `Current connection mode is ${this.stealthConnectionKind}; plugin handshake only applies to CDP`;
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:tab-group-handshake',
|
||||
pluginResult.status,
|
||||
pluginResult.message,
|
||||
{ configuredPluginId: pluginIntent.pluginId }
|
||||
);
|
||||
} else {
|
||||
pluginResult.mode = 'cdp';
|
||||
try {
|
||||
const page = this.getPage();
|
||||
if (page.isClosed()) {
|
||||
pluginResult.status = 'fail';
|
||||
pluginResult.message = 'Active page is closed; cannot run plugin handshake';
|
||||
} else if (!this.canInjectTabGroupScript(page)) {
|
||||
pluginResult.status = 'warn';
|
||||
pluginResult.message =
|
||||
'Active page is an internal browser page; open a normal http(s) page to test plugin handshake';
|
||||
} else {
|
||||
const response = await this.requestTabGroupPlugin(page, pluginIntent);
|
||||
if (!response) {
|
||||
pluginResult.status = 'fail';
|
||||
pluginResult.message = 'Plugin handshake timed out';
|
||||
this.setTabGroupCapability(pluginIntent.session, 'unavailable');
|
||||
} else if (!response.ok) {
|
||||
pluginResult.status = 'fail';
|
||||
pluginResult.message = response.error
|
||||
? `Plugin handshake failed: ${response.error}`
|
||||
: 'Plugin handshake failed';
|
||||
this.setTabGroupCapability(pluginIntent.session, 'unavailable');
|
||||
} else if (response.extensionId !== pluginIntent.pluginId) {
|
||||
pluginResult.status = 'fail';
|
||||
pluginResult.message = `Plugin id mismatch: expected ${pluginIntent.pluginId}, got ${response.extensionId ?? 'missing'}`;
|
||||
pluginResult.extensionId = response.extensionId;
|
||||
this.setTabGroupCapability(pluginIntent.session, 'unavailable');
|
||||
} else {
|
||||
pluginResult.status = 'pass';
|
||||
pluginResult.message = 'Plugin handshake succeeded';
|
||||
pluginResult.extensionId = response.extensionId;
|
||||
this.setTabGroupCapability(pluginIntent.session, 'available');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
pluginResult.status = 'fail';
|
||||
pluginResult.message =
|
||||
error instanceof Error ? error.message : `Plugin handshake failed: ${String(error)}`;
|
||||
}
|
||||
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:tab-group-handshake',
|
||||
pluginResult.status,
|
||||
pluginResult.message,
|
||||
{
|
||||
configuredPluginId: pluginIntent.pluginId,
|
||||
...(pluginResult.extensionId ? { extensionId: pluginResult.extensionId } : {}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const ok = checks.every((check) => check.status !== 'fail');
|
||||
return {
|
||||
ok,
|
||||
checks,
|
||||
context: {
|
||||
launched: this.isLaunched(),
|
||||
connectionKind: this.stealthConnectionKind,
|
||||
cdpEndpoint: this.cdpEndpoint,
|
||||
session: this.getAgentSessionName(),
|
||||
},
|
||||
cdp: {
|
||||
preferredPort,
|
||||
discovered,
|
||||
devToolsActivePort,
|
||||
},
|
||||
plugin: pluginResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up console, error, and close tracking for a page
|
||||
*/
|
||||
|
||||
+50
-1
@@ -412,11 +412,13 @@ export async function startDaemon(options?: {
|
||||
|
||||
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
|
||||
// Default behavior for this fork: attach to an existing browser only.
|
||||
const isDoctor = parseResult.command.action === 'doctor';
|
||||
if (
|
||||
!manager.isLaunched() &&
|
||||
parseResult.command.action !== 'launch' &&
|
||||
parseResult.command.action !== 'close' &&
|
||||
parseResult.command.action !== 'state_load'
|
||||
parseResult.command.action !== 'state_load' &&
|
||||
parseResult.command.action !== 'doctor'
|
||||
) {
|
||||
if (isIOS && manager instanceof IOSManager) {
|
||||
// Auto-launch iOS Safari
|
||||
@@ -548,6 +550,53 @@ export async function startDaemon(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
// For doctor, attempt the same default attach flow but do not fail hard if attach is unavailable.
|
||||
// This keeps diagnostics actionable even when CDP is down.
|
||||
if (!manager.isLaunched() && isDoctor && manager instanceof BrowserManager) {
|
||||
try {
|
||||
await manager.launch({
|
||||
id: 'doctor-cdp',
|
||||
action: 'launch',
|
||||
cdpPort: 9333,
|
||||
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
colorScheme:
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
|
||||
? (process.env.AGENT_BROWSER_COLOR_SCHEME as 'dark' | 'light' | 'no-preference')
|
||||
: undefined,
|
||||
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
|
||||
tabGroupPluginId:
|
||||
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
await manager.launch({
|
||||
id: 'doctor-auto-connect',
|
||||
action: 'launch',
|
||||
autoConnect: true,
|
||||
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
colorScheme:
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
|
||||
? (process.env.AGENT_BROWSER_COLOR_SCHEME as
|
||||
| 'dark'
|
||||
| 'light'
|
||||
| 'no-preference')
|
||||
: undefined,
|
||||
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
|
||||
tabGroupPluginId:
|
||||
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
// Keep running: doctor should report failures instead of exiting early.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recover from stale state: browser is launched but all pages were closed
|
||||
if (
|
||||
manager instanceof BrowserManager &&
|
||||
|
||||
@@ -1464,6 +1464,14 @@ describe('parseCommand', () => {
|
||||
});
|
||||
|
||||
describe('invalid commands', () => {
|
||||
it('should parse doctor command', () => {
|
||||
const result = parseCommand(cmd({ id: '1', action: 'doctor' }));
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.command.action).toBe('doctor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject unknown action', () => {
|
||||
const result = parseCommand(cmd({ id: '1', action: 'unknown' }));
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
@@ -848,6 +848,10 @@ const closeSchema = baseCommandSchema.extend({
|
||||
action: z.literal('close'),
|
||||
});
|
||||
|
||||
const doctorSchema = baseCommandSchema.extend({
|
||||
action: z.literal('doctor'),
|
||||
});
|
||||
|
||||
// Tab/Window schemas
|
||||
const tabNewSchema = baseCommandSchema.extend({
|
||||
action: z.literal('tab_new'),
|
||||
@@ -955,6 +959,7 @@ const commandSchema = z.discriminatedUnion('action', [
|
||||
hoverSchema,
|
||||
contentSchema,
|
||||
closeSchema,
|
||||
doctorSchema,
|
||||
tabNewSchema,
|
||||
tabListSchema,
|
||||
tabSwitchSchema,
|
||||
|
||||
@@ -877,6 +877,10 @@ export interface CloseCommand extends BaseCommand {
|
||||
action: 'close';
|
||||
}
|
||||
|
||||
export interface DoctorCommand extends BaseCommand {
|
||||
action: 'doctor';
|
||||
}
|
||||
|
||||
// Tab/Window commands
|
||||
export interface TabNewCommand extends BaseCommand {
|
||||
action: 'tab_new';
|
||||
@@ -931,6 +935,7 @@ export type Command =
|
||||
| HoverCommand
|
||||
| ContentCommand
|
||||
| CloseCommand
|
||||
| DoctorCommand
|
||||
| TabNewCommand
|
||||
| TabListCommand
|
||||
| TabSwitchCommand
|
||||
@@ -1297,6 +1302,49 @@ export interface DiffUrlData {
|
||||
screenshot?: DiffScreenshotData;
|
||||
}
|
||||
|
||||
export type DoctorCheckStatus = 'pass' | 'warn' | 'fail' | 'skip';
|
||||
|
||||
export interface DoctorCheck {
|
||||
name: string;
|
||||
status: DoctorCheckStatus;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DoctorData {
|
||||
ok: boolean;
|
||||
checks: DoctorCheck[];
|
||||
context: {
|
||||
launched: boolean;
|
||||
connectionKind: string;
|
||||
cdpEndpoint?: string | null;
|
||||
session: string;
|
||||
};
|
||||
cdp: {
|
||||
preferredPort: number;
|
||||
discovered: Array<{
|
||||
port: number;
|
||||
status: DoctorCheckStatus;
|
||||
wsUrl?: string | null;
|
||||
source: 'preferred-port' | 'common-port' | 'devtools-active-port';
|
||||
note?: string;
|
||||
}>;
|
||||
devToolsActivePort: Array<{
|
||||
userDataDir: string;
|
||||
status: DoctorCheckStatus;
|
||||
port?: number;
|
||||
wsPath?: string;
|
||||
}>;
|
||||
};
|
||||
plugin: {
|
||||
configuredPluginId: string;
|
||||
status: DoctorCheckStatus;
|
||||
mode: 'cdp' | 'non-cdp' | 'not-launched';
|
||||
message: string;
|
||||
extensionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Browser state
|
||||
export interface BrowserState {
|
||||
browser: Browser | null;
|
||||
|
||||
Reference in New Issue
Block a user