From 726377c4c1d73b71d35d3b2abe6edf418947646c Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 3 Mar 2026 17:05:42 +0900 Subject: [PATCH] feat: add doctor diagnostics and bump to 0.15.2-fork.1 --- README.md | 15 ++ cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- cli/src/commands.rs | 53 +++--- cli/src/output.rs | 243 +++++++++++++++++++++++--- docs/src/app/commands/page.mdx | 2 + package.json | 3 +- skills/agent-browser/SKILL.md | 4 + src/actions.ts | 8 + src/browser.ts | 303 ++++++++++++++++++++++++++++++++- src/daemon.ts | 51 +++++- src/protocol.test.ts | 8 + src/protocol.ts | 5 + src/types.ts | 48 ++++++ 14 files changed, 687 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index da21e75..5400712 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 44f463b..fe62800 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -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", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 49b30f8..13c04a7 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -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" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 0a78581..7572f62 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -655,6 +655,17 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result 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); diff --git a/cli/src/output.rs b/cli/src/output.rs index 9146e2f..3f48c8b 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -38,7 +38,9 @@ fn truncate_if_needed(content: &str, max: Option) -> 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 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) { + 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) { 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)) diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 4dd8678..e8bb123 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -33,6 +33,7 @@ agent-browser pdf # Save page as PDF agent-browser snapshot # Accessibility tree with refs agent-browser eval # Run JavaScript agent-browser connect # 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 # Highlight element +agent-browser doctor # Diagnose CDP + plugin handshake status ``` ## State management diff --git a/package.json b/package.json index 00a8385..091f7b6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index b513cf1..a864004 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -52,6 +52,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle # Navigation agent-browser open # Navigate (aliases: goto, navigate) agent-browser --risk-mode block open # 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) diff --git a/src/actions.ts b/src/actions.ts index 15bb9c1..43f5dcd 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -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 { + const report = await browser.runDoctor(); + return successResponse(command.id, report); +} + async function handleNavigate( command: NavigateCommand, browser: BrowserManager diff --git a/src/browser.ts b/src/browser.ts index cb33b99..35111ed 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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 | 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 { + 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 | 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 + ): 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 { + const checks: DoctorCheck[] = []; + const preferredPort = 9333; + const discovered: DoctorData['cdp']['discovered'] = []; + const devToolsActivePort: DoctorData['cdp']['devToolsActivePort'] = []; + const seenPorts = new Set(); + + 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 */ diff --git a/src/daemon.ts b/src/daemon.ts index 6801e85..dc4dde4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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 && diff --git a/src/protocol.test.ts b/src/protocol.test.ts index ff387bf..a1057e8 100644 --- a/src/protocol.test.ts +++ b/src/protocol.test.ts @@ -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); diff --git a/src/protocol.ts b/src/protocol.ts index e436474..24bb608 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -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, diff --git a/src/types.ts b/src/types.ts index 1e58b33..0c95bd0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +} + +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;