Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae22bda46c | ||
|
|
d82357fea4 | ||
|
|
66a39f3c83 | ||
|
|
eedf824af7 | ||
|
|
c07eb7ee52 | ||
|
|
a493d02c66 | ||
|
|
c4180c8cb1 | ||
|
|
56260f68b0 | ||
|
|
324a9e4e0c | ||
|
|
7f42eed031 | ||
|
|
c10981413f | ||
|
|
05018b309a | ||
|
|
9d0454d229 | ||
|
|
51f5fa484c | ||
|
|
857c0b25df | ||
|
|
62241b50e9 | ||
|
|
c6a33b6338 | ||
|
|
d32a1d046a | ||
|
|
0c0ed5e72c | ||
|
|
34092ec193 | ||
|
|
726377c4c1 | ||
|
|
8e2e4abce6 | ||
|
|
870895e922 | ||
|
|
2a766cfe48 | ||
|
|
d04cf59238 |
+54
-44
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
version-sync:
|
||||
@@ -55,18 +56,35 @@ jobs:
|
||||
run: pnpm test
|
||||
|
||||
rust:
|
||||
name: Rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
|
||||
rust-cross:
|
||||
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- os: windows-latest
|
||||
- os: windows-latest-8-cores
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
steps:
|
||||
@@ -78,29 +96,19 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
workspaces: cli
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
windows-integration:
|
||||
name: Windows Integration Test
|
||||
runs-on: windows-latest
|
||||
needs: rust
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest-8-cores
|
||||
needs: rust-cross
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -122,18 +130,10 @@ jobs:
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: windows-cargo-x86_64-pc-windows-msvc-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
windows-cargo-x86_64-pc-windows-msvc-
|
||||
workspaces: cli
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
|
||||
@@ -173,6 +173,23 @@ jobs:
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Test daemon lifecycle (open, snapshot, close)
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
Write-Host "--- Opening page ---"
|
||||
bin/agent-browser-win32-x64.exe open https://example.com
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
|
||||
Write-Host "--- Taking snapshot ---"
|
||||
$snapshot = bin/agent-browser-win32-x64.exe snapshot
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "snapshot failed"; exit 1 }
|
||||
Write-Host $snapshot
|
||||
Write-Host "--- Closing browser ---"
|
||||
bin/agent-browser-win32-x64.exe close
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "close failed"; exit 1 }
|
||||
Write-Host "--- Windows daemon lifecycle test passed ---"
|
||||
shell: pwsh
|
||||
timeout-minutes: 5
|
||||
|
||||
serverless-chromium:
|
||||
name: Serverless Chromium (@sparticuz/chromium)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -206,8 +223,9 @@ jobs:
|
||||
|
||||
global-install:
|
||||
name: Global Install (${{ matrix.os }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: rust
|
||||
needs: rust-cross
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -217,7 +235,7 @@ jobs:
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
- os: windows-latest
|
||||
- os: windows-latest-8-cores
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: agent-browser-win32-x64.exe
|
||||
|
||||
@@ -241,18 +259,10 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
workspaces: cli
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
@@ -98,18 +98,10 @@ jobs:
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
workspaces: cli
|
||||
|
||||
- name: Build with zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
@@ -209,6 +201,10 @@ jobs:
|
||||
fi
|
||||
echo "All 5 platform binaries present and valid"
|
||||
|
||||
- name: Verify bundled binary versions
|
||||
run: |
|
||||
pnpm run verify:bundled-binaries
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
|
||||
@@ -26,6 +26,57 @@ This applies to changes that either human users or AI agents would need to know
|
||||
|
||||
In the `docs/src/app/` MDX files, always use HTML `<table>` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site.
|
||||
|
||||
## Dual Architecture (Node.js + Native)
|
||||
|
||||
The codebase has two daemon implementations:
|
||||
|
||||
- **Node.js/Playwright** (default) -- `src/daemon.ts`, `src/actions.ts`, `src/browser.ts`, and the rest of `src/`
|
||||
- **Rust/Native** (experimental, `--native` or `AGENT_BROWSER_NATIVE=1`) -- `cli/src/native/daemon.rs`, `cli/src/native/actions.rs`, `cli/src/native/browser.rs`, and the rest of `cli/src/native/`
|
||||
|
||||
When modifying browser automation logic (commands, actions, protocol handling), changes **must** be made in **both** paths:
|
||||
|
||||
| Node.js Path | Native Path |
|
||||
|---|---|
|
||||
| `src/actions.ts` | `cli/src/native/actions.rs` |
|
||||
| `src/browser.ts` | `cli/src/native/browser.rs` |
|
||||
| `src/daemon.ts` | `cli/src/native/daemon.rs` |
|
||||
| `src/protocol.ts` | `cli/src/native/cdp/client.rs` |
|
||||
| `src/snapshot.ts` | `cli/src/native/snapshot.rs` |
|
||||
| `src/state-utils.ts` | `cli/src/native/state.rs` |
|
||||
|
||||
New commands must be implemented in both paths, or explicitly stubbed in the native path with a clear `"Not yet implemented: {action}"` error. The goal is eventual full migration to native, but until then both paths must stay in sync.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
cd cli && cargo test
|
||||
```
|
||||
|
||||
Runs all unit tests (~320 tests). These are fast and don't require Chrome.
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
```bash
|
||||
cd cli && cargo test e2e -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
Runs 18 e2e tests that launch real headless Chrome instances and exercise the full native daemon command pipeline. Requirements:
|
||||
|
||||
- Chrome must be installed
|
||||
- Must run serially (`--test-threads=1`) to avoid Chrome instance contention
|
||||
- Tests are `#[ignore]`'d so they don't run during normal `cargo test`
|
||||
|
||||
The e2e tests live in `cli/src/native/e2e_tests.rs` and cover: launch/close, navigation, snapshots, screenshots, form interaction, cookies, storage, tabs, element queries, viewport/emulation, domain filtering, diff, state management, error handling, and Phase 8 commands.
|
||||
|
||||
### Linting and Formatting
|
||||
|
||||
```bash
|
||||
cd cli && cargo fmt -- --check # Check formatting
|
||||
cd cli && cargo clippy # Lint
|
||||
```
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
## Source Code Reference
|
||||
|
||||
@@ -5,7 +5,7 @@ Stealth-first fork of `agent-browser` for production browser automation under an
|
||||
This README focuses on stealth architecture and principles. For full command coverage inherited from upstream, use:
|
||||
|
||||
- upstream docs: <https://github.com/vercel-labs/agent-browser>
|
||||
- local help: `agent-browser --help`
|
||||
- local help: `agent-browser --help` (short alias: `abs --help`)
|
||||
|
||||
## What This Fork Optimizes
|
||||
|
||||
@@ -20,7 +20,7 @@ This README focuses on stealth architecture and principles. For full command cov
|
||||
People often ask this: "What's the anti-detection approach compared to `agent-browser-stealth` on npm?"
|
||||
|
||||
- `agent-browser-stealth` on npm is the package name for this fork.
|
||||
- The CLI keeps upstream-compatible command names (`agent-browser` is still the main executable, with `agent-browser-stealth` as an alias).
|
||||
- The CLI keeps upstream-compatible command names (`agent-browser` is still the main executable, with `agent-browser-stealth` and `abs` as aliases).
|
||||
- The practical difference vs upstream `agent-browser` is not one single "stealth switch"; it is a defense-in-depth stack designed for anti-bot pressure.
|
||||
|
||||
The core idea is layered hardening across the full automation lifecycle:
|
||||
@@ -40,6 +40,8 @@ Goal: reduce detection probability and improve stability in production automatio
|
||||
```bash
|
||||
npm install -g agent-browser-stealth
|
||||
agent-browser install
|
||||
# same CLI, short alias
|
||||
abs install
|
||||
```
|
||||
|
||||
### Minimal Usage
|
||||
@@ -50,6 +52,37 @@ agent-browser snapshot -i
|
||||
agent-browser click @e2
|
||||
```
|
||||
|
||||
### Default: Auto Group Agent Tabs (CDP + Plugin)
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com
|
||||
# In CDP mode, tabs are grouped when the tab-group extension is installed
|
||||
|
||||
# Override group title
|
||||
agent-browser --tab-group "My Agent Group" open https://example.com
|
||||
```
|
||||
|
||||
- CDP (`--cdp` / `--auto-connect`) keeps working unchanged.
|
||||
- If the extension is installed and handshake succeeds, agent tabs are grouped by session:
|
||||
- session=`default`: `Agent Browser Stealth`
|
||||
- other sessions: `Agent Browser Stealth • <session>`
|
||||
- If the extension is missing/unavailable, commands continue normally with silent no-op (no warning/error unless `AGENT_BROWSER_DEBUG=1`).
|
||||
- Env overrides:
|
||||
- `AGENT_BROWSER_TAB_GROUP` for base title
|
||||
- `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` for expected extension ID
|
||||
|
||||
Install once in Chrome: load unpacked extension from `extensions/tab-group-cdp/` (extension name: `agent-browser-stealth`).
|
||||
|
||||
### Extension Capabilities (`agent-browser-stealth`)
|
||||
|
||||
- Session window isolation: tabs are kept in their session window when possible.
|
||||
- Configurable isolation controls: side panel can toggle `strictWindowIsolation` and cross-window activation guard.
|
||||
- Session-aware grouping: deterministic group color, default session expanded, non-default sessions collapsed.
|
||||
- Download archive routing: downloads from managed tabs are routed to `agent-browser-stealth/<session>/...`.
|
||||
- Domain allowlist fallback: when allowlist is configured for a session, extension can force-block out-of-policy tabs to `about:blank`.
|
||||
- Risk hints (debug only): suspicious host/TLD hints are returned via handshake and printed only when `AGENT_BROWSER_DEBUG=1`.
|
||||
- Side panel console: view session/tab/group mapping, focus a session, keep only one session, clean empty groups, edit session allowlist, and toggle auto-clean.
|
||||
|
||||
## Stealth Architecture
|
||||
|
||||
```mermaid
|
||||
@@ -160,7 +193,7 @@ Manual overrides are supported:
|
||||
|
||||
## Principle 5: Verification-Aware Risk Control
|
||||
|
||||
When a navigation lands on verification/captcha pages, structured risk signals are generated from URL/title evidence.
|
||||
When a navigation lands on verification/captcha pages, structured risk signals are generated from URL/title/page-text evidence.
|
||||
|
||||
`riskSignals` include:
|
||||
|
||||
@@ -171,7 +204,7 @@ When a navigation lands on verification/captcha pages, structured risk signals a
|
||||
|
||||
### Risk Mode
|
||||
|
||||
- `warn` (default): retry with randomized backoff and return warnings + `riskSignals`.
|
||||
- `warn` (default): wait for auto-clear, then retry with randomized backoff and return warnings + `riskSignals`.
|
||||
- `block`: fail fast once verification/captcha interstitial is detected.
|
||||
- `off`: skip detection/retry path.
|
||||
|
||||
@@ -183,11 +216,11 @@ AGENT_BROWSER_RISK_MODE=off agent-browser open https://example.com
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Navigate"] --> B["Collect URL and Title Signals"]
|
||||
A["Navigate"] --> B["Collect URL/Title/Text Signals"]
|
||||
B --> C{"risk-mode"}
|
||||
C -->|off| D["Return Success"]
|
||||
C -->|block| E["Return Error with First Signal"]
|
||||
C -->|warn| F["Retry up to 2 times"]
|
||||
C -->|warn| F["Wait for auto-clear, then retry up to 2 times"]
|
||||
F --> G{"Signals Cleared"}
|
||||
G -->|yes| H["Return Success + recovery warning + riskSignals"]
|
||||
G -->|no| I["Return Success + warning + riskSignals"]
|
||||
@@ -196,8 +229,9 @@ flowchart TD
|
||||
## Operational Recommendations
|
||||
|
||||
- Prefer `--headed` for high-friction targets.
|
||||
- Reuse session state with `--session-name` for continuity.
|
||||
- Reuse session state with one stable `--session-name` for continuity (when omitted, it defaults to `--session`).
|
||||
- Keep locale/timezone consistent with target market.
|
||||
- For challenge-heavy pages, prefer `--wait-until domcontentloaded` on `open`/`navigate` to avoid `load` stalls.
|
||||
- Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages.
|
||||
- For `cookies set`, use either `--url <url>`, or `--domain <domain> --path <path>` together.
|
||||
- If `--url`, `--domain`, and `--path` are all omitted, the cookie is scoped from the current page URL.
|
||||
@@ -209,8 +243,27 @@ Run public detector checks after stealth changes:
|
||||
```bash
|
||||
node scripts/check-sannysoft-webdriver.js --binary ./cli/target/release/agent-browser
|
||||
node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser
|
||||
node scripts/check-stealth-regression.js --binary ./cli/target/release/agent-browser
|
||||
pnpm run check:turnstile-testkey
|
||||
```
|
||||
|
||||
## Doctor Diagnostics
|
||||
|
||||
Use `doctor` to quickly diagnose local CDP, sourceURL sanitization, 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
|
||||
- CDP Runtime.evaluate sourceURL sanitization probe
|
||||
- Plugin handshake page context check (internal page vs normal `http(s)` page)
|
||||
- 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
+2506
-38
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.15.2-fork.0"
|
||||
version = "0.16.1-fork.3"
|
||||
edition = "2021"
|
||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||
license = "Apache-2.0"
|
||||
@@ -19,6 +19,17 @@ serde_json = "1.0"
|
||||
dirs = "5.0"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
image = "0.25"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
||||
sha2 = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
similar = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -26,8 +37,17 @@ libc = "0.2"
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_System_Threading", "Win32_Foundation"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let protocol_dir = Path::new("cdp-protocol");
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let out_path = Path::new(&out_dir).join("cdp_generated.rs");
|
||||
|
||||
let browser_path = protocol_dir.join("browser_protocol.json");
|
||||
let js_path = protocol_dir.join("js_protocol.json");
|
||||
|
||||
if !browser_path.exists() && !js_path.exists() {
|
||||
fs::write(
|
||||
&out_path,
|
||||
"// No protocol JSON files found in cdp-protocol/\n",
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut all_domains: Vec<Domain> = Vec::new();
|
||||
|
||||
for path in [&browser_path, &js_path] {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let protocol: ProtocolSpec = match serde_json::from_str(&content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Failed to parse {}: {}", path.display(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
all_domains.extend(protocol.domains);
|
||||
}
|
||||
|
||||
// Collect all known type IDs per domain for cross-domain resolution
|
||||
let mut domain_types: std::collections::HashMap<String, HashSet<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for domain in &all_domains {
|
||||
let mut types = HashSet::new();
|
||||
for td in &domain.types {
|
||||
types.insert(td.id.clone());
|
||||
}
|
||||
domain_types.insert(domain.domain.clone(), types);
|
||||
}
|
||||
|
||||
// Known recursive struct fields that need Box wrapping
|
||||
let recursive_fields: HashSet<(&str, &str, &str)> = [
|
||||
("DOM", "Node", "contentDocument"),
|
||||
("DOM", "Node", "templateContent"),
|
||||
("DOM", "Node", "importedDocument"),
|
||||
("Accessibility", "AXNode", "sources"),
|
||||
("Runtime", "StackTrace", "parent"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
output.push_str("use serde::{Deserialize, Serialize};\n\n");
|
||||
|
||||
for domain in &all_domains {
|
||||
generate_domain(domain, &domain_types, &recursive_fields, &mut output);
|
||||
}
|
||||
|
||||
fs::write(&out_path, &output).unwrap();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ProtocolSpec {
|
||||
domains: Vec<Domain>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Domain {
|
||||
domain: String,
|
||||
#[serde(default)]
|
||||
types: Vec<TypeDef>,
|
||||
#[serde(default)]
|
||||
commands: Vec<Command>,
|
||||
#[serde(default)]
|
||||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct TypeDef {
|
||||
id: String,
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: String,
|
||||
#[serde(default)]
|
||||
properties: Vec<Property>,
|
||||
#[serde(rename = "enum", default)]
|
||||
enum_values: Vec<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Command {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
parameters: Vec<Property>,
|
||||
#[serde(default)]
|
||||
returns: Vec<Property>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Event {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
parameters: Vec<Property>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Property {
|
||||
name: String,
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: Option<String>,
|
||||
#[serde(rename = "$ref", default)]
|
||||
ref_type: Option<String>,
|
||||
#[serde(default)]
|
||||
optional: bool,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
items: Option<Box<ItemType>>,
|
||||
#[serde(rename = "enum", default)]
|
||||
enum_values: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct ItemType {
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: Option<String>,
|
||||
#[serde(rename = "$ref", default)]
|
||||
ref_type: Option<String>,
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut capitalize = true;
|
||||
for c in s.chars() {
|
||||
if c == '_' || c == '-' || c == '.' {
|
||||
capitalize = true;
|
||||
} else if capitalize {
|
||||
result.push(c.to_ascii_uppercase());
|
||||
capitalize = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn to_snake_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
// Only insert underscore at transitions from lowercase to uppercase,
|
||||
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
|
||||
let prev_upper = chars[i - 1].is_uppercase();
|
||||
let next_lower = chars.get(i + 1).map_or(false, |n| n.is_lowercase());
|
||||
if !prev_upper || next_lower {
|
||||
result.push('_');
|
||||
}
|
||||
}
|
||||
result.push(c.to_ascii_lowercase());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve a $ref type reference. Cross-domain refs like "Page.FrameId" become
|
||||
/// `super::cdp_page::FrameId`. Same-domain refs are used directly.
|
||||
fn resolve_ref(
|
||||
r: &str,
|
||||
current_domain: &str,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
) -> String {
|
||||
let parts: Vec<&str> = r.split('.').collect();
|
||||
if parts.len() == 2 {
|
||||
let ref_domain = parts[0];
|
||||
let ref_type = parts[1];
|
||||
if ref_domain == current_domain {
|
||||
to_pascal_case(ref_type)
|
||||
} else {
|
||||
// Check if this type actually exists in the referenced domain
|
||||
if domain_types
|
||||
.get(ref_domain)
|
||||
.map_or(false, |t| t.contains(ref_type))
|
||||
{
|
||||
format!(
|
||||
"super::cdp_{}::{}",
|
||||
to_snake_case(ref_domain),
|
||||
to_pascal_case(ref_type)
|
||||
)
|
||||
} else {
|
||||
// Fall back to serde_json::Value for unknown cross-domain refs
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
to_pascal_case(r)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_type_in_domain(
|
||||
prop: &Property,
|
||||
current_domain: &str,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
) -> String {
|
||||
if let Some(ref r) = prop.ref_type {
|
||||
let type_name = resolve_ref(r, current_domain, domain_types);
|
||||
if prop.optional {
|
||||
format!("Option<{}>", type_name)
|
||||
} else {
|
||||
type_name
|
||||
}
|
||||
} else if let Some(ref t) = prop.type_kind {
|
||||
let base = match t.as_str() {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
"object" => "serde_json::Value".to_string(),
|
||||
"any" => "serde_json::Value".to_string(),
|
||||
"array" => {
|
||||
if let Some(ref items) = prop.items {
|
||||
let inner = if let Some(ref r) = items.ref_type {
|
||||
resolve_ref(r, current_domain, domain_types)
|
||||
} else {
|
||||
match items.type_kind.as_deref().unwrap_or("any") {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
_ => "serde_json::Value".to_string(),
|
||||
}
|
||||
};
|
||||
format!("Vec<{}>", inner)
|
||||
} else {
|
||||
"Vec<serde_json::Value>".to_string()
|
||||
}
|
||||
}
|
||||
_ => "serde_json::Value".to_string(),
|
||||
};
|
||||
if prop.optional {
|
||||
format!("Option<{}>", base)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else if prop.optional {
|
||||
"Option<serde_json::Value>".to_string()
|
||||
} else {
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_rust_keyword(s: &str) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
"type"
|
||||
| "self"
|
||||
| "Self"
|
||||
| "super"
|
||||
| "move"
|
||||
| "ref"
|
||||
| "fn"
|
||||
| "mod"
|
||||
| "use"
|
||||
| "pub"
|
||||
| "let"
|
||||
| "mut"
|
||||
| "const"
|
||||
| "static"
|
||||
| "if"
|
||||
| "else"
|
||||
| "for"
|
||||
| "while"
|
||||
| "loop"
|
||||
| "match"
|
||||
| "return"
|
||||
| "break"
|
||||
| "continue"
|
||||
| "as"
|
||||
| "in"
|
||||
| "impl"
|
||||
| "trait"
|
||||
| "struct"
|
||||
| "enum"
|
||||
| "where"
|
||||
| "async"
|
||||
| "await"
|
||||
| "dyn"
|
||||
| "box"
|
||||
| "yield"
|
||||
| "override"
|
||||
| "crate"
|
||||
| "extern"
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_domain(
|
||||
domain: &Domain,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
recursive_fields: &HashSet<(&str, &str, &str)>,
|
||||
output: &mut String,
|
||||
) {
|
||||
let mod_name = to_snake_case(&domain.domain);
|
||||
output.push_str(&format!(
|
||||
"#[allow(dead_code, non_snake_case, non_camel_case_types, clippy::enum_variant_names)]\npub mod cdp_{} {{\n",
|
||||
mod_name
|
||||
));
|
||||
output.push_str(" use super::*;\n\n");
|
||||
|
||||
for type_def in &domain.types {
|
||||
if !type_def.enum_values.is_empty() {
|
||||
// Deduplicate enum variants (some CDP enums have duplicated PascalCase forms)
|
||||
let mut seen_variants = HashSet::new();
|
||||
output.push_str(" #[derive(Debug, Clone, Serialize, Deserialize)]\n");
|
||||
output.push_str(&format!(" pub enum {} {{\n", type_def.id));
|
||||
for val in &type_def.enum_values {
|
||||
let mut variant = to_pascal_case(val);
|
||||
if variant == "Self" {
|
||||
variant = "SelfValue".to_string();
|
||||
}
|
||||
if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) {
|
||||
variant = format!("V{}", variant);
|
||||
}
|
||||
if seen_variants.insert(variant.clone()) {
|
||||
output.push_str(&format!(
|
||||
" #[serde(rename = \"{}\")]\n {},\n",
|
||||
val, variant
|
||||
));
|
||||
}
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
} else if type_def.type_kind == "object" && !type_def.properties.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {} {{\n", type_def.id));
|
||||
for prop in &type_def.properties {
|
||||
let field_name = to_snake_case(&prop.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let mut rust_type = map_type_in_domain(prop, &domain.domain, domain_types);
|
||||
|
||||
// Wrap recursive fields in Box
|
||||
if recursive_fields.contains(&(
|
||||
domain.domain.as_str(),
|
||||
type_def.id.as_str(),
|
||||
prop.name.as_str(),
|
||||
)) {
|
||||
if rust_type.starts_with("Option<") {
|
||||
let inner = &rust_type[7..rust_type.len() - 1];
|
||||
rust_type = format!("Option<Box<{}>>", inner);
|
||||
} else {
|
||||
rust_type = format!("Box<{}>", rust_type);
|
||||
}
|
||||
}
|
||||
|
||||
if prop.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
} else if type_def.type_kind == "object" && type_def.properties.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" pub type {} = serde_json::Value;\n\n",
|
||||
type_def.id
|
||||
));
|
||||
} else if type_def.type_kind == "array" {
|
||||
output.push_str(&format!(
|
||||
" pub type {} = Vec<serde_json::Value>;\n\n",
|
||||
type_def.id
|
||||
));
|
||||
} else if type_def.type_kind == "string" && type_def.enum_values.is_empty() {
|
||||
output.push_str(&format!(" pub type {} = String;\n\n", type_def.id));
|
||||
} else if type_def.type_kind == "integer" {
|
||||
output.push_str(&format!(" pub type {} = i64;\n\n", type_def.id));
|
||||
} else if type_def.type_kind == "number" {
|
||||
output.push_str(&format!(" pub type {} = f64;\n\n", type_def.id));
|
||||
}
|
||||
}
|
||||
|
||||
for cmd in &domain.commands {
|
||||
let pascal_name = to_pascal_case(&cmd.name);
|
||||
|
||||
if !cmd.parameters.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Params {{\n", pascal_name));
|
||||
for param in &cmd.parameters {
|
||||
let field_name = to_snake_case(¶m.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
|
||||
if param.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
|
||||
if !cmd.returns.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Result {{\n", pascal_name));
|
||||
for ret in &cmd.returns {
|
||||
let field_name = to_snake_case(&ret.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(ret, &domain.domain, domain_types);
|
||||
if ret.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
for event in &domain.events {
|
||||
if !event.parameters.is_empty() {
|
||||
let pascal_name = to_pascal_case(&event.name);
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Event {{\n", pascal_name));
|
||||
for param in &event.parameters {
|
||||
let field_name = to_snake_case(¶m.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
|
||||
if param.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str("}\n\n");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+61
-21
@@ -197,6 +197,22 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(ref wait_until) = flags.wait_until {
|
||||
if matches!(
|
||||
wait_until.as_str(),
|
||||
"load" | "domcontentloaded" | "networkidle"
|
||||
) {
|
||||
nav_cmd["waitUntil"] = json!(wait_until);
|
||||
} else {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!(
|
||||
"Invalid --wait-until value: {} (expected load, domcontentloaded, or networkidle)",
|
||||
wait_until
|
||||
),
|
||||
usage: "open <url>",
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(nav_cmd)
|
||||
}
|
||||
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||
@@ -655,6 +671,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 {
|
||||
@@ -2054,7 +2081,12 @@ mod tests {
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
tab_group: None,
|
||||
tab_group_plugin_id: None,
|
||||
risk_mode: None,
|
||||
wait_until: None,
|
||||
cli_tab_group: false,
|
||||
cli_tab_group_plugin_id: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2334,6 +2366,14 @@ mod tests {
|
||||
assert_eq!(cmd["riskMode"], "block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_wait_until() {
|
||||
let mut flags = default_flags();
|
||||
flags.wait_until = Some("domcontentloaded".to_string());
|
||||
let cmd = parse_command(&args("open https://example.com"), &flags).unwrap();
|
||||
assert_eq!(cmd["waitUntil"], "domcontentloaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_multiple_headers() {
|
||||
let mut flags = default_flags();
|
||||
@@ -2372,16 +2412,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");
|
||||
}
|
||||
@@ -2959,6 +2995,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());
|
||||
@@ -3713,11 +3764,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);
|
||||
@@ -3726,11 +3773,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);
|
||||
@@ -3739,11 +3783,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);
|
||||
|
||||
+23
-38
@@ -118,6 +118,12 @@ fn get_pid_path(session: &str) -> PathBuf {
|
||||
|
||||
/// Clean up stale socket and PID files for a session
|
||||
fn cleanup_stale_files(session: &str) {
|
||||
// Never delete files for a live daemon. A missing PID file can happen in
|
||||
// race scenarios, but the socket is authoritative for liveness.
|
||||
if daemon_ready(session) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pid_path = get_pid_path(session);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
|
||||
@@ -150,42 +156,6 @@ fn get_port_for_session(session: &str) -> u16 {
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<i32>() {
|
||||
unsafe {
|
||||
if libc::kill(pid, 0) == 0 {
|
||||
return true;
|
||||
}
|
||||
// EPERM means the process exists but we lack permission to
|
||||
// signal it (e.g. inside a macOS sandbox). Only ESRCH means
|
||||
// the process is genuinely gone.
|
||||
return std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn daemon_ready(session: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -227,9 +197,12 @@ pub fn ensure_daemon(
|
||||
session_name: Option<&str>,
|
||||
debug: bool,
|
||||
download_path: Option<&str>,
|
||||
tab_group: Option<&str>,
|
||||
tab_group_plugin_id: Option<&str>,
|
||||
) -> Result<DaemonResult, String> {
|
||||
// Check if daemon is running AND responsive
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
// Socket readiness is the source of truth for a usable daemon.
|
||||
// PID files can be missing/stale under concurrent start/stop races.
|
||||
if daemon_ready(session) {
|
||||
// Double-check it's actually responsive by waiting and checking again
|
||||
// This handles the race condition where daemon is shutting down
|
||||
// (daemon has a 100ms shutdown delay, so we wait longer)
|
||||
@@ -374,6 +347,12 @@ pub fn ensure_daemon(
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
if let Some(tg) = tab_group {
|
||||
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
|
||||
}
|
||||
if let Some(plugin_id) = tab_group_plugin_id {
|
||||
cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id);
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
@@ -461,6 +440,12 @@ pub fn ensure_daemon(
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
if let Some(tg) = tab_group {
|
||||
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
|
||||
}
|
||||
if let Some(plugin_id) = tab_group_plugin_id {
|
||||
cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id);
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
|
||||
+228
-3
@@ -1,4 +1,4 @@
|
||||
use crate::color;
|
||||
use crate::{color, validation};
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@@ -7,6 +7,8 @@ use std::path::{Path, PathBuf};
|
||||
const CONFIG_DIR: &str = ".agent-browser";
|
||||
const CONFIG_FILENAME: &str = "config.json";
|
||||
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
|
||||
const DEFAULT_TAB_GROUP: &str = "Agent Browser Stealth";
|
||||
const DEFAULT_TAB_GROUP_PLUGIN_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -34,7 +36,10 @@ pub struct Config {
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub tab_group: Option<String>,
|
||||
pub tab_group_plugin_id: Option<String>,
|
||||
pub risk_mode: Option<String>,
|
||||
pub wait_until: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -69,7 +74,10 @@ impl Config {
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
download_path: other.download_path.or(self.download_path),
|
||||
tab_group: other.tab_group.or(self.tab_group),
|
||||
tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id),
|
||||
risk_mode: other.risk_mode.or(self.risk_mode),
|
||||
wait_until: other.wait_until.or(self.wait_until),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +144,10 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--color-scheme",
|
||||
"--channel",
|
||||
"--download-path",
|
||||
"--tab-group",
|
||||
"--tab-group-plugin-id",
|
||||
"--risk-mode",
|
||||
"--wait-until",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -203,13 +214,18 @@ pub struct Flags {
|
||||
pub allow_file_access: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub session_name: Option<String>,
|
||||
pub session_name: Option<String>, // Defaults to --session when unset
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub tab_group: Option<String>,
|
||||
pub tab_group_plugin_id: Option<String>,
|
||||
/// How verification/captcha detections are handled on navigation:
|
||||
/// `off` (disable), `warn` (retry and warn), `block` (fail fast).
|
||||
pub risk_mode: Option<String>,
|
||||
/// Navigation wait strategy passed to navigate/open commands:
|
||||
/// `load`, `domcontentloaded`, or `networkidle`.
|
||||
pub wait_until: Option<String>,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
@@ -223,6 +239,8 @@ pub struct Flags {
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_tab_group: bool,
|
||||
pub cli_tab_group_plugin_id: bool,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -289,12 +307,22 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
|
||||
.ok()
|
||||
.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),
|
||||
tab_group: env::var("AGENT_BROWSER_TAB_GROUP")
|
||||
.ok()
|
||||
.or(config.tab_group)
|
||||
.or_else(|| Some(DEFAULT_TAB_GROUP.to_string())),
|
||||
tab_group_plugin_id: env::var("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID")
|
||||
.ok()
|
||||
.or(config.tab_group_plugin_id)
|
||||
.or_else(|| Some(DEFAULT_TAB_GROUP_PLUGIN_ID.to_string())),
|
||||
risk_mode: env::var("AGENT_BROWSER_RISK_MODE")
|
||||
.ok()
|
||||
.or(config.risk_mode)
|
||||
.map(|s| s.to_ascii_lowercase()),
|
||||
wait_until: config.wait_until.map(|s| s.to_ascii_lowercase()),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_state: false,
|
||||
@@ -305,6 +333,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_tab_group: false,
|
||||
cli_tab_group_plugin_id: false,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -466,12 +496,32 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
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;
|
||||
}
|
||||
}
|
||||
"--tab-group-plugin-id" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.tab_group_plugin_id = Some(s.clone());
|
||||
flags.cli_tab_group_plugin_id = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--risk-mode" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.risk_mode = Some(s.to_ascii_lowercase());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--wait-until" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.wait_until = Some(s.to_ascii_lowercase());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--config" => {
|
||||
// Already handled by load_config(); skip the value
|
||||
i += 1;
|
||||
@@ -480,6 +530,18 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Keep auth/state continuity stable by default: if no explicit --session-name
|
||||
// is provided, derive it from --session (or fall back to "default" when invalid).
|
||||
if flags.session_name.is_none() {
|
||||
let derived = if validation::is_valid_session_name(&flags.session) {
|
||||
flags.session.clone()
|
||||
} else {
|
||||
"default".to_string()
|
||||
};
|
||||
flags.session_name = Some(derived);
|
||||
}
|
||||
|
||||
flags
|
||||
}
|
||||
|
||||
@@ -516,7 +578,10 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--download-path",
|
||||
"--tab-group",
|
||||
"--tab-group-plugin-id",
|
||||
"--risk-mode",
|
||||
"--wait-until",
|
||||
"--config",
|
||||
];
|
||||
|
||||
@@ -551,6 +616,36 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => env::set_var(name, v),
|
||||
None => env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn args(s: &str) -> Vec<String> {
|
||||
s.split_whitespace().map(String::from).collect()
|
||||
@@ -664,6 +759,19 @@ mod tests {
|
||||
));
|
||||
assert_eq!(flags.session, "test");
|
||||
assert_eq!(flags.executable_path, Some("/custom/chrome".to_string()));
|
||||
assert_eq!(flags.session_name.as_deref(), Some("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_name_defaults_to_session_when_not_provided() {
|
||||
let flags = parse_flags(&args("--session my-session snapshot"));
|
||||
assert_eq!(flags.session_name.as_deref(), Some("my-session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_session_falls_back_to_default_session_name() {
|
||||
let flags = parse_flags(&args("--session bad/session snapshot"));
|
||||
assert_eq!(flags.session_name.as_deref(), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -714,6 +822,104 @@ mod tests {
|
||||
assert!(!flags.cli_download_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_tab_group_is_enabled() {
|
||||
let flags = parse_flags(&args("snapshot"));
|
||||
assert_eq!(flags.tab_group.as_deref(), Some(DEFAULT_TAB_GROUP));
|
||||
assert!(!flags.cli_tab_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_tab_group_plugin_id_is_enabled() {
|
||||
let flags = parse_flags(&args("snapshot"));
|
||||
assert_eq!(
|
||||
flags.tab_group_plugin_id.as_deref(),
|
||||
Some(DEFAULT_TAB_GROUP_PLUGIN_ID)
|
||||
);
|
||||
assert!(!flags.cli_tab_group_plugin_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_group_flag() {
|
||||
let input = vec![
|
||||
"--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]
|
||||
fn test_parse_tab_group_plugin_id_flag() {
|
||||
let input = vec![
|
||||
"--tab-group-plugin-id".to_string(),
|
||||
"cli-plugin-id".to_string(),
|
||||
"snapshot".to_string(),
|
||||
];
|
||||
let flags = parse_flags(&input);
|
||||
assert_eq!(flags.tab_group_plugin_id.as_deref(), Some("cli-plugin-id"));
|
||||
assert!(flags.cli_tab_group_plugin_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_tab_group_plugin_id() {
|
||||
let cleaned = clean_args(&args(
|
||||
"--tab-group-plugin-id cli-plugin-id open example.com",
|
||||
));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_group_plugin_id_precedence_config_env_cli() {
|
||||
use std::io::Write;
|
||||
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_TAB_GROUP_PLUGIN_ID"]);
|
||||
|
||||
let dir = std::env::temp_dir().join("ab-test-plugin-id-precedence");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let config_path = dir.join("config.json");
|
||||
let mut f = fs::File::create(&config_path).unwrap();
|
||||
writeln!(f, r#"{{"tabGroupPluginId":"config-plugin-id"}}"#).unwrap();
|
||||
|
||||
env::set_var("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", "env-plugin-id");
|
||||
|
||||
let env_args = vec![
|
||||
"--config".to_string(),
|
||||
config_path.to_string_lossy().to_string(),
|
||||
"snapshot".to_string(),
|
||||
];
|
||||
let flags_from_env = parse_flags(&env_args);
|
||||
assert_eq!(
|
||||
flags_from_env.tab_group_plugin_id.as_deref(),
|
||||
Some("env-plugin-id")
|
||||
);
|
||||
|
||||
let cli_args = vec![
|
||||
"--config".to_string(),
|
||||
config_path.to_string_lossy().to_string(),
|
||||
"--tab-group-plugin-id".to_string(),
|
||||
"cli-plugin-id".to_string(),
|
||||
"snapshot".to_string(),
|
||||
];
|
||||
let flags_from_cli = parse_flags(&cli_args);
|
||||
assert_eq!(
|
||||
flags_from_cli.tab_group_plugin_id.as_deref(),
|
||||
Some("cli-plugin-id")
|
||||
);
|
||||
assert!(flags_from_cli.cli_tab_group_plugin_id);
|
||||
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_risk_mode_flag() {
|
||||
let flags = parse_flags(&args("--risk-mode block open example.com"));
|
||||
@@ -726,6 +932,18 @@ mod tests {
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wait_until_flag() {
|
||||
let flags = parse_flags(&args("--wait-until domcontentloaded open example.com"));
|
||||
assert_eq!(flags.wait_until.as_deref(), Some("domcontentloaded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_wait_until() {
|
||||
let cleaned = clean_args(&args("--wait-until networkidle open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_multiple_flags_tracking() {
|
||||
let flags = parse_flags(&args(
|
||||
@@ -762,6 +980,8 @@ mod tests {
|
||||
"cdp": "9222",
|
||||
"autoConnect": true,
|
||||
"headers": "{\"Auth\":\"token\"}",
|
||||
"tabGroup": "Agent Browser Stealth",
|
||||
"tabGroupPluginId": "tab-group-plugin-id",
|
||||
"riskMode": "block"
|
||||
}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
@@ -788,6 +1008,11 @@ mod tests {
|
||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||
assert_eq!(config.auto_connect, Some(true));
|
||||
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
|
||||
assert_eq!(config.tab_group.as_deref(), Some("Agent Browser Stealth"));
|
||||
assert_eq!(
|
||||
config.tab_group_plugin_id.as_deref(),
|
||||
Some("tab-group-plugin-id")
|
||||
);
|
||||
assert_eq!(config.risk_mode.as_deref(), Some("block"));
|
||||
}
|
||||
|
||||
|
||||
+42
-2
@@ -287,6 +287,8 @@ fn main() {
|
||||
flags.session_name.as_deref(),
|
||||
flags.debug,
|
||||
flags.download_path.as_deref(),
|
||||
flags.tab_group.as_deref(),
|
||||
flags.tab_group_plugin_id.as_deref(),
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
@@ -338,6 +340,10 @@ fn main() {
|
||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||
flags.cli_download_path.then_some("--download-path"),
|
||||
flags.cli_tab_group.then_some("--tab-group"),
|
||||
flags
|
||||
.cli_tab_group_plugin_id
|
||||
.then_some("--tab-group-plugin-id"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -424,6 +430,12 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
@@ -516,6 +528,12 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
@@ -549,6 +567,12 @@ fn main() {
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) => {
|
||||
@@ -563,7 +587,6 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
}
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
@@ -601,6 +624,12 @@ fn main() {
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
if let Ok(resp) = send_command(launch_cmd, &flags.session) {
|
||||
attached_to_existing_browser = resp.success;
|
||||
@@ -616,6 +645,12 @@ fn main() {
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
auto_connect_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
auto_connect_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
auto_connect_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
if let Ok(resp) = send_command(auto_connect_cmd, &flags.session) {
|
||||
attached_to_existing_browser = resp.success;
|
||||
@@ -708,6 +743,12 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) => {
|
||||
@@ -723,7 +764,6 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
}
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthProfile {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub submit_selector: Option<String>,
|
||||
}
|
||||
|
||||
// Keep legacy Credential alias for backward compatibility
|
||||
pub type Credential = AuthProfile;
|
||||
|
||||
fn validate_profile_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid profile name '{}'. Must match /^[a-zA-Z0-9_-]+$/",
|
||||
name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_auth_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("auth")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("auth")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile_path(name: &str) -> PathBuf {
|
||||
get_auth_dir().join(format!("{}.json", name))
|
||||
}
|
||||
|
||||
fn derive_encryption_key() -> Vec<u8> {
|
||||
let hostname = std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("COMPUTERNAME"))
|
||||
.unwrap_or_else(|_| {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut buf = [0u8; 256];
|
||||
let len = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||
if len == 0 {
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..end]).to_string()
|
||||
} else {
|
||||
"unknown-host".to_string()
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
"unknown-host".to_string()
|
||||
}
|
||||
});
|
||||
let username = std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "unknown-user".to_string());
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("agent-browser:{}:{}", hostname, username).as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<Vec<u8>, String> {
|
||||
let key = derive_encryption_key();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
|
||||
|
||||
let plaintext = serde_json::to_string(profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&nonce), plaintext.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
if data.len() < 13 {
|
||||
return Err("Encrypted data too short".to_string());
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
|
||||
let key = derive_encryption_key();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e))
|
||||
}
|
||||
|
||||
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
|
||||
let dir = get_auth_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
|
||||
let encrypted = encrypt_profile(profile)?;
|
||||
let path = get_profile_path(&profile.name);
|
||||
fs::write(&path, &encrypted).map_err(|e| format!("Failed to write profile: {}", e))
|
||||
}
|
||||
|
||||
fn load_profile(name: &str) -> Result<AuthProfile, String> {
|
||||
let path = get_profile_path(name);
|
||||
if !path.exists() {
|
||||
return Err(format!("Auth profile '{}' not found", name));
|
||||
}
|
||||
let data = fs::read(&path).map_err(|e| format!("Failed to read profile: {}", e))?;
|
||||
decrypt_profile(&data)
|
||||
}
|
||||
|
||||
pub fn credentials_set(
|
||||
name: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
url: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = AuthProfile {
|
||||
name: name.to_string(),
|
||||
url: url.unwrap_or("").to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
}
|
||||
|
||||
pub fn auth_save(
|
||||
name: &str,
|
||||
url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
username_selector: Option<&str>,
|
||||
password_selector: Option<&str>,
|
||||
submit_selector: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = AuthProfile {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
username_selector: username_selector.map(String::from),
|
||||
password_selector: password_selector.map(String::from),
|
||||
submit_selector: submit_selector.map(String::from),
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
}
|
||||
|
||||
pub fn credentials_get(name: &str) -> Result<Value, String> {
|
||||
let profile = load_profile(name)?;
|
||||
Ok(json!({
|
||||
"name": profile.name,
|
||||
"username": profile.username,
|
||||
"url": profile.url,
|
||||
"hasPassword": true,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn credentials_get_full(name: &str) -> Result<AuthProfile, String> {
|
||||
load_profile(name)
|
||||
}
|
||||
|
||||
pub fn credentials_delete(name: &str) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let path = get_profile_path(name);
|
||||
if !path.exists() {
|
||||
return Err(format!("Auth profile '{}' not found", name));
|
||||
}
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to delete profile: {}", e))?;
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
pub fn credentials_list() -> Result<Value, String> {
|
||||
let dir = get_auth_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "profiles": [] }));
|
||||
}
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
match load_profile(&name) {
|
||||
Ok(profile) => {
|
||||
profiles.push(json!({
|
||||
"name": profile.name,
|
||||
"username": profile.username,
|
||||
"url": profile.url,
|
||||
}));
|
||||
}
|
||||
Err(_) => {
|
||||
profiles.push(json!({
|
||||
"name": name,
|
||||
"error": "Failed to decrypt",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "profiles": profiles }))
|
||||
}
|
||||
|
||||
pub fn auth_show(name: &str) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = load_profile(name)?;
|
||||
Ok(json!({
|
||||
"profile": {
|
||||
"name": profile.name,
|
||||
"url": profile.url,
|
||||
"username": profile.username,
|
||||
"usernameSelector": profile.username_selector,
|
||||
"passwordSelector": profile.password_selector,
|
||||
"submitSelector": profile.submit_selector,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_profile_name() {
|
||||
assert!(validate_profile_name("github").is_ok());
|
||||
assert!(validate_profile_name("my-app").is_ok());
|
||||
assert!(validate_profile_name("test_123").is_ok());
|
||||
assert!(validate_profile_name("").is_err());
|
||||
assert!(validate_profile_name("has space").is_err());
|
||||
assert!(validate_profile_name("../evil").is_err());
|
||||
assert!(validate_profile_name("foo/bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_profile_serialization() {
|
||||
let profile = AuthProfile {
|
||||
name: "test".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: Some("button[type=submit]".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&profile).unwrap();
|
||||
let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, "test");
|
||||
assert_eq!(
|
||||
parsed.submit_selector,
|
||||
Some("button[type=submit]".to_string())
|
||||
);
|
||||
assert!(parsed.username_selector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let profile = AuthProfile {
|
||||
name: "roundtrip".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "s3cret!".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(&encrypted).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_encryption_key_is_stable() {
|
||||
let k1 = derive_encryption_key();
|
||||
let k2 = derive_encryption_key();
|
||||
assert_eq!(k1, k2);
|
||||
assert_eq!(k1.len(), 32);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,635 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::types::BrowserVersionInfo;
|
||||
|
||||
pub struct ChromeProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
}
|
||||
|
||||
impl ChromeProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChromeProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LaunchOptions {
|
||||
pub headless: bool,
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub proxy_bypass: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub args: Vec<String>,
|
||||
pub allow_file_access: bool,
|
||||
pub extensions: Option<Vec<String>>,
|
||||
pub storage_state: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for LaunchOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
headless: true,
|
||||
executable_path: None,
|
||||
proxy: None,
|
||||
proxy_bypass: None,
|
||||
profile: None,
|
||||
args: Vec::new(),
|
||||
allow_file_access: false,
|
||||
extensions: None,
|
||||
storage_state: None,
|
||||
user_agent: None,
|
||||
ignore_https_errors: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
let chrome_path = match &options.executable_path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => {
|
||||
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
|
||||
}
|
||||
};
|
||||
|
||||
let mut args = vec![
|
||||
"--remote-debugging-port=0".to_string(),
|
||||
"--no-first-run".to_string(),
|
||||
"--no-default-browser-check".to_string(),
|
||||
"--disable-background-networking".to_string(),
|
||||
"--disable-backgrounding-occluded-windows".to_string(),
|
||||
"--disable-component-update".to_string(),
|
||||
"--disable-default-apps".to_string(),
|
||||
"--disable-hang-monitor".to_string(),
|
||||
"--disable-popup-blocking".to_string(),
|
||||
"--disable-prompt-on-repost".to_string(),
|
||||
"--disable-sync".to_string(),
|
||||
"--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
|
||||
"--metrics-recording-only".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
];
|
||||
|
||||
if options.headless {
|
||||
args.push("--headless=new".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref proxy) = options.proxy {
|
||||
args.push(format!("--proxy-server={}", proxy));
|
||||
}
|
||||
|
||||
if let Some(ref bypass) = options.proxy_bypass {
|
||||
args.push(format!("--proxy-bypass-list={}", bypass));
|
||||
}
|
||||
|
||||
if let Some(ref profile) = options.profile {
|
||||
let expanded = expand_tilde(profile);
|
||||
args.push(format!("--user-data-dir={}", expanded));
|
||||
}
|
||||
|
||||
if options.allow_file_access {
|
||||
args.push("--allow-file-access-from-files".to_string());
|
||||
args.push("--allow-file-access".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref exts) = options.extensions {
|
||||
if !exts.is_empty() {
|
||||
let ext_list = exts.join(",");
|
||||
args.push(format!("--load-extension={}", ext_list));
|
||||
args.push(format!("--disable-extensions-except={}", ext_list));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user args set window size (skip viewport override)
|
||||
let has_window_size = options
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
|
||||
|
||||
if !has_window_size && options.headless {
|
||||
args.push("--window-size=1280,720".to_string());
|
||||
}
|
||||
|
||||
args.extend(options.args.iter().cloned());
|
||||
|
||||
if should_disable_sandbox(&args) {
|
||||
args.push("--no-sandbox".to_string());
|
||||
}
|
||||
|
||||
let mut child = Command::new(&chrome_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Chrome at {:?}: {}", chrome_path, e))?;
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or("Failed to capture Chrome stderr")?;
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
let ws_url = wait_for_ws_url(reader)?;
|
||||
|
||||
Ok(ChromeProcess { child, ws_url })
|
||||
}
|
||||
|
||||
fn wait_for_ws_url(reader: BufReader<std::process::ChildStderr>) -> Result<String, String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
let prefix = "DevTools listening on ";
|
||||
let mut stderr_lines: Vec<String> = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err(chrome_launch_error(
|
||||
"Timeout waiting for Chrome DevTools URL",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
let line = line.map_err(|e| format!("Failed to read Chrome stderr: {}", e))?;
|
||||
if let Some(url) = line.strip_prefix(prefix) {
|
||||
return Ok(url.trim().to_string());
|
||||
}
|
||||
stderr_lines.push(line);
|
||||
}
|
||||
|
||||
Err(chrome_launch_error(
|
||||
"Chrome exited before providing DevTools URL",
|
||||
&stderr_lines,
|
||||
))
|
||||
}
|
||||
|
||||
fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
|
||||
let relevant: Vec<&String> = stderr_lines
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
let lower = l.to_lowercase();
|
||||
lower.contains("error")
|
||||
|| lower.contains("fatal")
|
||||
|| lower.contains("sandbox")
|
||||
|| lower.contains("namespace")
|
||||
|| lower.contains("permission")
|
||||
|| lower.contains("cannot")
|
||||
|| lower.contains("failed")
|
||||
|| lower.contains("abort")
|
||||
})
|
||||
.collect();
|
||||
|
||||
if relevant.is_empty() {
|
||||
if stderr_lines.is_empty() {
|
||||
return format!("{} (no stderr output from Chrome)", message);
|
||||
}
|
||||
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
|
||||
return format!(
|
||||
"{}\nChrome stderr (last {} lines):\n {}",
|
||||
message,
|
||||
last_lines.len(),
|
||||
last_lines
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
let hint = if relevant.iter().any(|l| {
|
||||
let lower = l.to_lowercase();
|
||||
lower.contains("sandbox") || lower.contains("namespace")
|
||||
}) {
|
||||
"\nHint: try --args \"--no-sandbox\" (required in containers, VMs, and some Linux setups)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}\nChrome stderr:\n {}{}",
|
||||
message,
|
||||
relevant
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n "),
|
||||
hint
|
||||
)
|
||||
}
|
||||
|
||||
pub fn find_chrome() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let candidates = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
];
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = find_playwright_chromium() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let candidates = [
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium-browser",
|
||||
"chromium",
|
||||
];
|
||||
for name in &candidates {
|
||||
if let Ok(output) = Command::new("which").arg(name).output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = find_playwright_chromium() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let candidates = [
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
];
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
let p = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
|
||||
let url = format!("http://127.0.0.1:{}/json/version", port);
|
||||
|
||||
let body = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
reqwest_get_string(&url).await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to CDP on port {}", port))?
|
||||
.map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?;
|
||||
|
||||
let info: BrowserVersionInfo = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Invalid /json/version response: {}", e))?;
|
||||
|
||||
info.web_socket_debugger_url
|
||||
.ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port))
|
||||
}
|
||||
|
||||
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||
let client = tokio::net::TcpStream::connect(
|
||||
url.strip_prefix("http://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1:9222"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let path = url
|
||||
.find('/')
|
||||
.and_then(|i| url[i..].find('/').map(|j| &url[i + j..]))
|
||||
.unwrap_or("/json/version");
|
||||
|
||||
let host = url
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1");
|
||||
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
||||
path, host
|
||||
);
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut client = client;
|
||||
client
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
client
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
let body = response_str
|
||||
.split("\r\n\r\n")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
|
||||
let path = user_data_dir.join("DevToolsActivePort");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
let mut lines = content.lines();
|
||||
let port: u16 = lines.next()?.trim().parse().ok()?;
|
||||
let ws_path = lines
|
||||
.next()
|
||||
.unwrap_or("/devtools/browser")
|
||||
.trim()
|
||||
.to_string();
|
||||
Some((port, ws_path))
|
||||
}
|
||||
|
||||
pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||
let user_data_dirs = get_chrome_user_data_dirs();
|
||||
|
||||
for dir in &user_data_dirs {
|
||||
if let Some((port, ws_path)) = read_devtools_active_port(dir) {
|
||||
// Try HTTP endpoint first (pre-M144)
|
||||
if let Ok(ws_url) = discover_cdp_url(port).await {
|
||||
return Ok(ws_url);
|
||||
}
|
||||
// M144+: direct WebSocket
|
||||
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
|
||||
return Ok(ws_url);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: probe common ports
|
||||
for port in [9222u16, 9229] {
|
||||
if let Ok(ws_url) = discover_cdp_url(port).await {
|
||||
return Ok(ws_url);
|
||||
}
|
||||
}
|
||||
|
||||
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
|
||||
}
|
||||
|
||||
fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let base = home.join("Library/Application Support");
|
||||
for name in ["Google/Chrome", "Google/Chrome Canary", "Chromium"] {
|
||||
dirs.push(base.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let config = home.join(".config");
|
||||
for name in ["google-chrome", "google-chrome-unstable", "chromium"] {
|
||||
dirs.push(config.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
let base = PathBuf::from(local);
|
||||
for name in [
|
||||
r"Google\Chrome\User Data",
|
||||
r"Google\Chrome SxS\User Data",
|
||||
r"Chromium\User Data",
|
||||
] {
|
||||
dirs.push(base.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Returns true if Chrome's sandbox should be disabled because the environment
|
||||
/// doesn't support it (containers, VMs, running as root).
|
||||
fn should_disable_sandbox(existing_args: &[String]) -> bool {
|
||||
if existing_args.iter().any(|a| a == "--no-sandbox") {
|
||||
return false; // already set by user
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Root user -- standard container default, Chrome sandbox requires non-root
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Docker container
|
||||
if Path::new("/.dockerenv").exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Podman container
|
||||
if Path::new("/run/.containerenv").exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generic container detection: cgroup contains docker/kubepods/lxc
|
||||
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
|
||||
if cgroup.contains("docker")
|
||||
|| cgroup.contains("kubepods")
|
||||
|| cgroup.contains("lxc")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Search Playwright's browser cache for a Chromium binary.
|
||||
/// This is where `agent-browser install` (via `npx playwright install chromium`) puts it.
|
||||
fn find_playwright_chromium() -> Option<PathBuf> {
|
||||
let mut search_dirs = Vec::new();
|
||||
|
||||
if let Ok(custom) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
|
||||
search_dirs.push(PathBuf::from(custom));
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
search_dirs.push(home.join(".cache/ms-playwright"));
|
||||
}
|
||||
|
||||
for dir in &search_dirs {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
let mut matches: Vec<PathBuf> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map(|n| n.starts_with("chromium-"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let candidate = build_playwright_binary_path(&e.path());
|
||||
if candidate.exists() {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Sort descending so the newest version wins
|
||||
matches.sort();
|
||||
matches.reverse();
|
||||
if let Some(p) = matches.into_iter().next() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
|
||||
chromium_dir.join("chrome-linux64/chrome")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
|
||||
chromium_dir.join("chrome-mac/Chromium.app/Contents/MacOS/Chromium")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
|
||||
chromium_dir.join("chrome-win/chrome.exe")
|
||||
}
|
||||
|
||||
fn expand_tilde(path: &str) -> String {
|
||||
if let Some(rest) = path.strip_prefix('~') {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home
|
||||
.join(rest.strip_prefix('/').unwrap_or(rest))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_chrome_returns_some_on_host() {
|
||||
// This test only makes sense on systems with Chrome installed
|
||||
if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
|
||||
let result = find_chrome();
|
||||
// Don't assert Some -- CI may not have Chrome
|
||||
if let Some(path) = result {
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tilde() {
|
||||
let expanded = expand_tilde("~/test/path");
|
||||
assert!(!expanded.starts_with('~'));
|
||||
assert!(expanded.ends_with("test/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tilde_no_tilde() {
|
||||
assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_devtools_active_port_missing() {
|
||||
let result = read_devtools_active_port(Path::new("/nonexistent"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_disable_sandbox_skips_if_already_set() {
|
||||
let args = vec!["--headless=new".to_string(), "--no-sandbox".to_string()];
|
||||
assert!(!should_disable_sandbox(&args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_no_stderr() {
|
||||
let msg = chrome_launch_error("Chrome exited", &[]);
|
||||
assert!(msg.contains("no stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_with_sandbox_hint() {
|
||||
let lines = vec![
|
||||
"some log line".to_string(),
|
||||
"Failed to move to new namespace: sandbox error".to_string(),
|
||||
];
|
||||
let msg = chrome_launch_error("Chrome exited", &lines);
|
||||
assert!(msg.contains("sandbox error"));
|
||||
assert!(msg.contains("Hint:"));
|
||||
assert!(msg.contains("--no-sandbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_generic() {
|
||||
let lines = vec![
|
||||
"info line".to_string(),
|
||||
"another info line".to_string(),
|
||||
];
|
||||
let msg = chrome_launch_error("Chrome exited", &lines);
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_playwright_chromium_nonexistent() {
|
||||
// With no Playwright cache, should return None
|
||||
std::env::set_var("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
|
||||
let result = find_playwright_chromium();
|
||||
std::env::remove_var("PLAYWRIGHT_BROWSERS_PATH");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, oneshot, Mutex};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::{CdpCommand, CdpEvent, CdpMessage};
|
||||
|
||||
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
|
||||
|
||||
pub struct CdpClient {
|
||||
ws_tx: Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
next_id: AtomicU64,
|
||||
pending: PendingMap,
|
||||
event_tx: broadcast::Sender<CdpEvent>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CdpClient {
|
||||
pub async fn connect(url: &str) -> Result<Self, String> {
|
||||
let (ws_stream, _) = connect_async(url)
|
||||
.await
|
||||
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
|
||||
let pending_clone = pending.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
let msg = match msg {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let parsed: CdpMessage = match serde_json::from_str(&msg) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = parsed.id {
|
||||
// Response to a command
|
||||
let mut pending = pending_clone.lock().await;
|
||||
if let Some(tx) = pending.remove(&id) {
|
||||
let _ = tx.send(parsed);
|
||||
}
|
||||
} else if let Some(ref method) = parsed.method {
|
||||
// Event
|
||||
let event = CdpEvent {
|
||||
method: method.clone(),
|
||||
params: parsed.params.clone().unwrap_or(Value::Null),
|
||||
session_id: parsed.session_id.clone(),
|
||||
};
|
||||
let _ = event_tx_clone.send(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
ws_tx,
|
||||
next_id: AtomicU64::new(1),
|
||||
pending,
|
||||
event_tx,
|
||||
_reader_handle: reader_handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_command(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Option<Value>,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let cmd = CdpCommand {
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: session_id.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&cmd)
|
||||
.map_err(|e| format!("Failed to serialize CDP command: {}", e))?;
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(id, tx);
|
||||
}
|
||||
|
||||
{
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send CDP command: {}", e))?;
|
||||
}
|
||||
|
||||
let response = match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await {
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(_)) => return Err("CDP response channel closed".to_string()),
|
||||
Err(_) => {
|
||||
self.pending.lock().await.remove(&id);
|
||||
return Err(format!("CDP command timed out: {}", method));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(error) = response.error {
|
||||
return Err(format!("CDP error ({}): {}", method, error));
|
||||
}
|
||||
|
||||
Ok(response.result.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: &P,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<R, String> {
|
||||
let params_value = serde_json::to_value(params)
|
||||
.map_err(|e| format!("Failed to serialize params: {}", e))?;
|
||||
let result = self
|
||||
.send_command(method, Some(params_value), session_id)
|
||||
.await?;
|
||||
serde_json::from_value(result)
|
||||
.map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
|
||||
}
|
||||
|
||||
pub async fn send_command_no_params(
|
||||
&self,
|
||||
method: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
self.send_command(method, None, session_id).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,537 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP message envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CdpCommand {
|
||||
pub id: u64,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CdpMessage {
|
||||
pub id: Option<u64>,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<CdpError>,
|
||||
pub method: Option<String>,
|
||||
pub params: Option<Value>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CdpError {
|
||||
pub code: Option<i64>,
|
||||
pub message: String,
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CdpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP events (broadcast to subscribers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdpEvent {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInfo {
|
||||
pub target_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub attached: Option<bool>,
|
||||
pub browser_context_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetTargetsResult {
|
||||
pub target_infos: Vec<TargetInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachToTargetParams {
|
||||
pub target_id: String,
|
||||
pub flatten: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachToTargetResult {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDiscoverTargetsParams {
|
||||
pub discover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetParams {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetResult {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloseTargetParams {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
// Target events
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetCreatedEvent {
|
||||
pub target_info: TargetInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetDestroyedEvent {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInfoChangedEvent {
|
||||
pub target_info: TargetInfo,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNavigateParams {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub referrer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNavigateResult {
|
||||
pub frame_id: String,
|
||||
pub loader_id: Option<String>,
|
||||
pub error_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameNavigatedEvent {
|
||||
pub frame: FrameInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameInfo {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
// Page.javascriptDialogOpening
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JavascriptDialogOpeningEvent {
|
||||
pub url: String,
|
||||
pub message: String,
|
||||
#[serde(rename = "type")]
|
||||
pub dialog_type: String,
|
||||
pub default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HandleJavaScriptDialogParams {
|
||||
pub accept: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_text: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluateParams {
|
||||
pub expression: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluateResult {
|
||||
pub result: RemoteObject,
|
||||
pub exception_details: Option<ExceptionDetails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteObject {
|
||||
#[serde(rename = "type")]
|
||||
pub object_type: String,
|
||||
pub subtype: Option<String>,
|
||||
pub value: Option<Value>,
|
||||
pub description: Option<String>,
|
||||
pub object_id: Option<String>,
|
||||
pub class_name: Option<String>,
|
||||
pub unserializable_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExceptionDetails {
|
||||
pub text: String,
|
||||
pub exception: Option<RemoteObject>,
|
||||
pub line_number: Option<i64>,
|
||||
pub column_number: Option<i64>,
|
||||
}
|
||||
|
||||
// Runtime.consoleAPICalled
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsoleApiCalledEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub args: Vec<RemoteObject>,
|
||||
pub timestamp: Option<f64>,
|
||||
}
|
||||
|
||||
// Runtime.exceptionThrown
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExceptionThrownEvent {
|
||||
pub timestamp: f64,
|
||||
pub exception_details: ExceptionDetails,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFullAXTreeResult {
|
||||
pub nodes: Vec<AXNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXNode {
|
||||
pub node_id: String,
|
||||
pub role: Option<AXValue>,
|
||||
pub name: Option<AXValue>,
|
||||
pub value: Option<AXValue>,
|
||||
pub description: Option<AXValue>,
|
||||
pub properties: Option<Vec<AXProperty>>,
|
||||
pub child_ids: Option<Vec<String>>,
|
||||
pub backend_d_o_m_node_id: Option<i64>,
|
||||
pub ignored: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXValue {
|
||||
#[serde(rename = "type")]
|
||||
pub value_type: String,
|
||||
pub value: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXProperty {
|
||||
pub name: String,
|
||||
pub value: AXValue,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network domain (minimal for Phase 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestWillBeSentEvent {
|
||||
pub request_id: String,
|
||||
pub request: NetworkRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkRequest {
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadingFinishedEvent {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadingFailedEvent {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomResolveNodeParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomResolveNodeResult {
|
||||
pub object: RemoteObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetBoxModelParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetBoxModelResult {
|
||||
pub model: BoxModel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoxModel {
|
||||
pub content: Vec<f64>,
|
||||
pub padding: Vec<f64>,
|
||||
pub border: Vec<f64>,
|
||||
pub margin: Vec<f64>,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomQuerySelectorParams {
|
||||
pub node_id: i64,
|
||||
pub selector: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomQuerySelectorResult {
|
||||
pub node_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetDocumentParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub depth: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetDocumentResult {
|
||||
pub root: DomNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomNode {
|
||||
pub node_id: i64,
|
||||
pub backend_node_id: Option<i64>,
|
||||
pub node_type: Option<i64>,
|
||||
pub node_name: Option<String>,
|
||||
pub children: Option<Vec<DomNode>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchMouseEventParams {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub button: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub buttons: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub click_count: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta_x: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta_y: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchKeyEventParams {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unmodified_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub windows_virtual_key_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub native_virtual_key_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InsertTextParams {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page.captureScreenshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureScreenshotParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Viewport>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from_surface: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capture_beyond_viewport: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureScreenshotResult {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime.callFunctionOn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallFunctionOnParams {
|
||||
pub function_declaration: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Vec<CallArgument>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallArgument {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version info (from /json/version)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrowserVersionInfo {
|
||||
#[serde(rename = "webSocketDebuggerUrl")]
|
||||
pub web_socket_debugger_url: Option<String>,
|
||||
#[serde(rename = "Browser")]
|
||||
pub browser: Option<String>,
|
||||
}
|
||||
|
||||
/// Auto-generated CDP types from protocol JSON files in `cdp-protocol/`.
|
||||
///
|
||||
/// To populate: download `browser_protocol.json` and `js_protocol.json` from
|
||||
/// <https://github.com/nicolo-ribaudo/nicolo-ribaudo.github.io/> (or any
|
||||
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
|
||||
///
|
||||
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cookie {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub domain: String,
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub expires: f64,
|
||||
#[serde(default)]
|
||||
pub size: i64,
|
||||
#[serde(default)]
|
||||
pub http_only: bool,
|
||||
#[serde(default)]
|
||||
pub secure: bool,
|
||||
#[serde(default)]
|
||||
pub session: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
urls: Option<Vec<String>>,
|
||||
) -> Result<Vec<Cookie>, String> {
|
||||
let params = match urls {
|
||||
Some(ref u) if !u.is_empty() => json!({ "urls": u }),
|
||||
_ => json!({}),
|
||||
};
|
||||
|
||||
let result = client
|
||||
.send_command("Network.getCookies", Some(params), Some(session_id))
|
||||
.await?;
|
||||
|
||||
let cookies: Vec<Cookie> = result
|
||||
.get("cookies")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
pub async fn set_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
cookies: Vec<Value>,
|
||||
current_url: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let cookies: Vec<Value> = cookies
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
// Auto-fill url if no domain/path/url provided
|
||||
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
|
||||
c.as_object_mut().map(|m| {
|
||||
m.insert(
|
||||
"url".to_string(),
|
||||
Value::String(current_url.unwrap().to_string()),
|
||||
)
|
||||
});
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Network.setCookies",
|
||||
Some(json!({ "cookies": cookies })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_cookies(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
client
|
||||
.send_command_no_params("Network.clearBrowserCookies", Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::signal;
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
use super::state;
|
||||
|
||||
pub async fn run_daemon(session: &str) {
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
}
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
|
||||
if socket_path.exists() {
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
||||
if let Ok(days) = days_str.parse::<u64>() {
|
||||
if days > 0 {
|
||||
let _ = state::state_clean(days);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = run_socket_server(&socket_path, session).await;
|
||||
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Daemon error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> {
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
let listener =
|
||||
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let port = get_port_for_session(session);
|
||||
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind TCP: {}", e))?;
|
||||
|
||||
let socket_dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
let port_path = socket_dir.join(format!("{}.port", session));
|
||||
let _ = fs::write(&port_path, port.to_string());
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
let _ = fs::remove_file(&port_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match buf_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if looks_like_http(trimmed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmd: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("Invalid JSON: {}", e),
|
||||
});
|
||||
let mut resp = serde_json::to_string(&err).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
let _ = writer.write_all(resp.as_bytes()).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
|
||||
|
||||
let response = {
|
||||
let mut s = state.lock().await;
|
||||
execute_command(&cmd, &mut s).await
|
||||
};
|
||||
|
||||
let mut resp = serde_json::to_string(&response).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
if writer.write_all(resp.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
if is_close {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_http(line: &str) -> bool {
|
||||
let prefixes = [
|
||||
"GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "CONNECT ", "TRACE ",
|
||||
];
|
||||
prefixes.iter().any(|p| line.starts_with(p))
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGINT handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sigterm = match signal::unix::signal(signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGTERM handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sighup = match signal::unix::signal(signal::unix::SignalKind::hangup()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGHUP handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {}
|
||||
_ = sigterm.recv() => {}
|
||||
_ = sighup.recv() => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
eprintln!("Failed to install Ctrl+C handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_daemon_socket_dir() -> PathBuf {
|
||||
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("agent-browser");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".agent-browser");
|
||||
}
|
||||
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i64 = 0;
|
||||
for b in session.bytes() {
|
||||
hash = hash.wrapping_mul(31).wrapping_add(b as i64);
|
||||
}
|
||||
49152 + (hash.unsigned_abs() % 16383) as u16
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use serde_json::{json, Value};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
pub struct ScreenshotDiffResult {
|
||||
pub total_pixels: u64,
|
||||
pub different_pixels: u64,
|
||||
pub mismatch_percentage: f64,
|
||||
pub matched: bool,
|
||||
pub diff_image: Option<Vec<u8>>,
|
||||
pub dimension_mismatch: Option<Value>,
|
||||
}
|
||||
|
||||
pub struct SnapshotDiffResult {
|
||||
pub diff: String,
|
||||
pub additions: usize,
|
||||
pub removals: usize,
|
||||
pub unchanged: usize,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
pub fn diff_screenshot(
|
||||
baseline: &[u8],
|
||||
current: &[u8],
|
||||
threshold: f64,
|
||||
) -> Result<ScreenshotDiffResult, String> {
|
||||
let img_a = image::load_from_memory(baseline)
|
||||
.map_err(|e| format!("Failed to decode baseline image: {}", e))?;
|
||||
let img_b = image::load_from_memory(current)
|
||||
.map_err(|e| format!("Failed to decode current image: {}", e))?;
|
||||
|
||||
let (wa, ha) = (img_a.width(), img_a.height());
|
||||
let (wb, hb) = (img_b.width(), img_b.height());
|
||||
|
||||
if wa != wb || ha != hb {
|
||||
return Ok(ScreenshotDiffResult {
|
||||
total_pixels: (wa as u64) * (ha as u64),
|
||||
different_pixels: (wa as u64) * (ha as u64),
|
||||
mismatch_percentage: 100.0,
|
||||
matched: false,
|
||||
diff_image: None,
|
||||
dimension_mismatch: Some(json!({
|
||||
"expected": { "width": wa, "height": ha },
|
||||
"actual": { "width": wb, "height": hb },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
let rgba_a = img_a.to_rgba8();
|
||||
let rgba_b = img_b.to_rgba8();
|
||||
let total = (wa as u64) * (ha as u64);
|
||||
let max_color_distance = threshold * 255.0 * (3.0_f64).sqrt();
|
||||
let mut different = 0u64;
|
||||
|
||||
let mut diff_img = image::RgbaImage::new(wa, ha);
|
||||
|
||||
for y in 0..ha {
|
||||
for x in 0..wa {
|
||||
let pa = rgba_a.get_pixel(x, y);
|
||||
let pb = rgba_b.get_pixel(x, y);
|
||||
let dr = (pa[0] as f64) - (pb[0] as f64);
|
||||
let dg = (pa[1] as f64) - (pb[1] as f64);
|
||||
let db = (pa[2] as f64) - (pb[2] as f64);
|
||||
let dist = (dr * dr + dg * dg + db * db).sqrt();
|
||||
|
||||
if dist > max_color_distance {
|
||||
different += 1;
|
||||
diff_img.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
|
||||
} else {
|
||||
let gray = ((pa[0] as u16 + pa[1] as u16 + pa[2] as u16) / 3) as u8;
|
||||
let dimmed = (gray as f64 * 0.3) as u8;
|
||||
diff_img.put_pixel(x, y, image::Rgba([dimmed, dimmed, dimmed, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mismatch = if total > 0 {
|
||||
(different as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let diff_bytes = if different > 0 {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
diff_img
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode diff image: {}", e))?;
|
||||
Some(buf.into_inner())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ScreenshotDiffResult {
|
||||
total_pixels: total,
|
||||
different_pixels: different,
|
||||
mismatch_percentage: mismatch,
|
||||
matched: different == 0,
|
||||
diff_image: diff_bytes,
|
||||
dimension_mismatch: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute a snapshot diff using the Myers algorithm via the `similar` crate.
|
||||
pub fn diff_snapshots(before: &str, after: &str) -> SnapshotDiffResult {
|
||||
let text_diff = TextDiff::from_lines(before, after);
|
||||
|
||||
let mut additions = 0usize;
|
||||
let mut removals = 0usize;
|
||||
let mut unchanged = 0usize;
|
||||
|
||||
for change in text_diff.iter_all_changes() {
|
||||
match change.tag() {
|
||||
ChangeTag::Insert => additions += 1,
|
||||
ChangeTag::Delete => removals += 1,
|
||||
ChangeTag::Equal => unchanged += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let changed = additions > 0 || removals > 0;
|
||||
|
||||
let diff = text_diff
|
||||
.unified_diff()
|
||||
.context_radius(3)
|
||||
.header("before", "after")
|
||||
.to_string();
|
||||
|
||||
SnapshotDiffResult {
|
||||
diff,
|
||||
additions,
|
||||
removals,
|
||||
unchanged,
|
||||
changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy JSON diff output for backwards compatibility.
|
||||
pub fn diff_text(a: &str, b: &str) -> Value {
|
||||
let result = diff_snapshots(a, b);
|
||||
json!({
|
||||
"identical": !result.changed,
|
||||
"additions": result.additions,
|
||||
"removals": result.removals,
|
||||
"deletions": result.removals,
|
||||
"unchanged": result.unchanged,
|
||||
"changed": result.changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn diff_unified(a: &str, b: &str) -> String {
|
||||
diff_snapshots(a, b).diff
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_diff_identical() {
|
||||
let result = diff_text("hello\nworld", "hello\nworld");
|
||||
assert_eq!(result.get("identical").unwrap(), true);
|
||||
assert_eq!(result.get("changed").unwrap(), false);
|
||||
assert_eq!(result.get("unchanged").unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_additions() {
|
||||
let result = diff_text("hello\n", "hello\nworld\n");
|
||||
assert_eq!(result.get("identical").unwrap(), false);
|
||||
assert_eq!(result.get("changed").unwrap(), true);
|
||||
assert!(result.get("additions").unwrap().as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_deletions() {
|
||||
let result = diff_text("hello\nworld\n", "hello\n");
|
||||
assert_eq!(result.get("identical").unwrap(), false);
|
||||
assert!(result.get("removals").unwrap().as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_unified_output() {
|
||||
let output = diff_unified("a\nb\nc\n", "a\nx\nc\n");
|
||||
assert!(output.contains("---"));
|
||||
assert!(output.contains("+++"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_diff_struct() {
|
||||
let result = diff_snapshots("line1\nline2\n", "line1\nline3\n");
|
||||
assert!(result.changed);
|
||||
assert_eq!(result.additions, 1);
|
||||
assert_eq!(result.removals, 1);
|
||||
assert_eq!(result.unchanged, 1);
|
||||
assert!(!result.diff.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,718 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefEntry {
|
||||
pub backend_node_id: Option<i64>,
|
||||
pub role: String,
|
||||
pub name: String,
|
||||
pub nth: Option<usize>,
|
||||
pub selector: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RefMap {
|
||||
map: HashMap<String, RefEntry>,
|
||||
next_ref: usize,
|
||||
}
|
||||
|
||||
impl RefMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
next_ref: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
&mut self,
|
||||
ref_id: String,
|
||||
backend_node_id: Option<i64>,
|
||||
role: &str,
|
||||
name: &str,
|
||||
nth: Option<usize>,
|
||||
) {
|
||||
self.map.insert(
|
||||
ref_id,
|
||||
RefEntry {
|
||||
backend_node_id,
|
||||
role: role.to_string(),
|
||||
name: name.to_string(),
|
||||
nth,
|
||||
selector: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn get(&self, ref_id: &str) -> Option<&RefEntry> {
|
||||
self.map.get(ref_id)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.next_ref = 1;
|
||||
}
|
||||
|
||||
pub fn next_ref_num(&self) -> usize {
|
||||
self.next_ref
|
||||
}
|
||||
|
||||
pub fn set_next_ref_num(&mut self, n: usize) {
|
||||
self.next_ref = n;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_ref(input: &str) -> Option<String> {
|
||||
let trimmed = input.trim();
|
||||
|
||||
if let Some(stripped) = trimmed.strip_prefix('@') {
|
||||
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(stripped.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(stripped) = trimmed.strip_prefix("ref=") {
|
||||
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(stripped.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed.starts_with('e')
|
||||
&& trimmed.len() > 1
|
||||
&& trimmed[1..].chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn resolve_element_center(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(f64, f64), String> {
|
||||
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||
let entry = ref_map
|
||||
.get(&ref_id)
|
||||
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
||||
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
let result: DomGetBoxModelResult = client
|
||||
.send_command_typed(
|
||||
"DOM.getBoxModel",
|
||||
&DomGetBoxModelParams {
|
||||
backend_node_id: Some(backend_node_id),
|
||||
node_id: None,
|
||||
object_id: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(box_model_center(&result.model));
|
||||
}
|
||||
|
||||
// Fallback: use role/name to find via JS
|
||||
return resolve_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth).await;
|
||||
}
|
||||
|
||||
// CSS selector
|
||||
resolve_by_selector(client, session_id, selector_or_ref).await
|
||||
}
|
||||
|
||||
pub async fn resolve_element_object_id(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||
let entry = ref_map
|
||||
.get(&ref_id)
|
||||
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
||||
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
let result: DomResolveNodeResult = client
|
||||
.send_command_typed(
|
||||
"DOM.resolveNode",
|
||||
&DomResolveNodeParams {
|
||||
backend_node_id: Some(backend_node_id),
|
||||
node_id: None,
|
||||
object_group: Some("agent-browser".to_string()),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
return result
|
||||
.object
|
||||
.object_id
|
||||
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
|
||||
}
|
||||
}
|
||||
|
||||
// CSS selector fallback
|
||||
let js = format!(
|
||||
"document.querySelector({})",
|
||||
serde_json::to_string(selector_or_ref).unwrap_or_default()
|
||||
);
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(false),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
result
|
||||
.result
|
||||
.object_id
|
||||
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))
|
||||
}
|
||||
|
||||
async fn resolve_by_role_name(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
role: &str,
|
||||
name: &str,
|
||||
nth: Option<usize>,
|
||||
) -> Result<(f64, f64), String> {
|
||||
let nth_index = nth.unwrap_or(0);
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
const matches = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) {{
|
||||
const r = node.getAttribute('role') || node.tagName.toLowerCase();
|
||||
const n = node.getAttribute('aria-label') || node.textContent.trim().slice(0, 100);
|
||||
if (r === {role} && n === {name}) matches.push(node);
|
||||
}}
|
||||
const el = matches[{nth}];
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
||||
}})()"#,
|
||||
role = serde_json::to_string(role).unwrap_or_default(),
|
||||
name = serde_json::to_string(name).unwrap_or_default(),
|
||||
nth = nth_index,
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let val = result.result.value.unwrap_or(Value::Null);
|
||||
let x = val.get("x").and_then(|v| v.as_f64());
|
||||
let y = val.get("y").and_then(|v| v.as_f64());
|
||||
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) => Ok((x, y)),
|
||||
_ => Err(format!(
|
||||
"Could not locate element with role={} name={}",
|
||||
role, name
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_by_selector(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
selector: &str,
|
||||
) -> Result<(f64, f64), String> {
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const el = document.querySelector({sel});
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let val = result.result.value.unwrap_or(Value::Null);
|
||||
let x = val.get("x").and_then(|v| v.as_f64());
|
||||
let y = val.get("y").and_then(|v| v.as_f64());
|
||||
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) => Ok((x, y)),
|
||||
_ => Err(format!("Element not found: {}", selector)),
|
||||
}
|
||||
}
|
||||
|
||||
fn box_model_center(model: &BoxModel) -> (f64, f64) {
|
||||
// content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
|
||||
if model.content.len() >= 8 {
|
||||
let x = (model.content[0] + model.content[2] + model.content[4] + model.content[6]) / 4.0;
|
||||
let y = (model.content[1] + model.content[3] + model.content[5] + model.content[7]) / 4.0;
|
||||
(x, y)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_element_text(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { return this.innerText || this.textContent || ''; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_element_attribute(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
attribute: &str,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: format!(
|
||||
"function() {{ return this.getAttribute({}); }}",
|
||||
serde_json::to_string(attribute).unwrap_or_default()
|
||||
),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn is_element_visible(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(this);
|
||||
return rect.width > 0 && rect.height > 0 &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.display !== 'none' &&
|
||||
parseFloat(style.opacity) > 0;
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub async fn is_element_enabled(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return !this.disabled; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true))
|
||||
}
|
||||
|
||||
pub async fn is_element_checked(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return !!this.checked; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub async fn get_element_inner_text(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return this.innerText || ''; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_element_inner_html(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return this.innerHTML || ''; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_element_input_value(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { return typeof this.value === 'string' ? this.value : ''; }"
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn set_element_value(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let js = format!(
|
||||
"function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}",
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, EvaluateResult>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_element_bounding_box(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const r = this.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
result
|
||||
.result
|
||||
.value
|
||||
.ok_or_else(|| format!("Could not get bounding box for: {}", selector_or_ref))
|
||||
}
|
||||
|
||||
pub async fn get_element_count(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
selector: &str,
|
||||
) -> Result<i64, String> {
|
||||
let js = format!(
|
||||
"document.querySelectorAll({}).length",
|
||||
serde_json::to_string(selector).unwrap_or_default()
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.and_then(|v| v.as_i64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub async fn get_element_styles(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
properties: Option<Vec<String>>,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let js = match properties {
|
||||
Some(props) => {
|
||||
let props_json = serde_json::to_string(&props).unwrap_or("[]".to_string());
|
||||
format!(
|
||||
r#"function() {{
|
||||
const s = window.getComputedStyle(this);
|
||||
const props = {};
|
||||
const result = {{}};
|
||||
for (const p of props) result[p] = s.getPropertyValue(p);
|
||||
return result;
|
||||
}}"#,
|
||||
props_json
|
||||
)
|
||||
}
|
||||
None => r#"function() {
|
||||
const s = window.getComputedStyle(this);
|
||||
const result = {};
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const p = s[i];
|
||||
result[p] = s.getPropertyValue(p);
|
||||
}
|
||||
return result;
|
||||
}"#
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_at_prefix() {
|
||||
assert_eq!(parse_ref("@e1"), Some("e1".to_string()));
|
||||
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_equals_prefix() {
|
||||
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_bare() {
|
||||
assert_eq!(parse_ref("e1"), Some("e1".to_string()));
|
||||
assert_eq!(parse_ref("e42"), Some("e42".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_invalid() {
|
||||
assert_eq!(parse_ref("button"), None);
|
||||
assert_eq!(parse_ref("e"), None);
|
||||
assert_eq!(parse_ref("1"), None);
|
||||
assert_eq!(parse_ref(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ref_map_basic() {
|
||||
let mut map = RefMap::new();
|
||||
map.add("e1".to_string(), Some(42), "button", "Submit", None);
|
||||
assert!(map.get("e1").is_some());
|
||||
assert_eq!(map.get("e1").unwrap().role, "button");
|
||||
assert!(map.get("e2").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_box_model_center() {
|
||||
let model = BoxModel {
|
||||
content: vec![10.0, 20.0, 110.0, 20.0, 110.0, 60.0, 10.0, 60.0],
|
||||
padding: vec![],
|
||||
border: vec![],
|
||||
margin: vec![],
|
||||
width: 100,
|
||||
height: 40,
|
||||
};
|
||||
let (x, y) = box_model_center(&model);
|
||||
assert!((x - 60.0).abs() < 0.01);
|
||||
assert!((y - 40.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
|
||||
|
||||
pub async fn click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
button: &str,
|
||||
click_count: i32,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
dispatch_click(client, session_id, x, y, button, click_count).await
|
||||
}
|
||||
|
||||
pub async fn dblclick(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 2).await
|
||||
}
|
||||
|
||||
pub async fn hover(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseMoved".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: None,
|
||||
buttons: None,
|
||||
click_count: None,
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fill(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
// Focus the element
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id.clone()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Select all + delete to clear
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.select && this.select();
|
||||
this.value = '';
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert text
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.insertText",
|
||||
&InsertTextParams {
|
||||
text: value.to_string(),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn type_text(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
text: &str,
|
||||
clear: bool,
|
||||
delay_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
// Focus
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id.clone()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if clear {
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.select && this.select();
|
||||
this.value = '';
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let delay = delay_ms.unwrap_or(0);
|
||||
|
||||
for ch in text.chars() {
|
||||
let text_str = ch.to_string();
|
||||
let (key, code, key_code) = char_to_key_info(ch);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: Some(text_str.clone()),
|
||||
unmodified_text: Some(text_str.clone()),
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyUp".to_string(),
|
||||
key: Some(key),
|
||||
code: Some(code),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Result<(), String> {
|
||||
let (key_name, code, key_code) = named_key_info(key);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key_name.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyUp".to_string(),
|
||||
key: Some(key_name),
|
||||
code: Some(code),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn scroll(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: Option<&str>,
|
||||
delta_x: f64,
|
||||
delta_y: f64,
|
||||
) -> Result<(), String> {
|
||||
if let Some(sel) = selector_or_ref {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, sel).await?;
|
||||
let js = "function(dx, dy) { this.scrollBy(dx, dy); }".to_string();
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: Some(vec![
|
||||
CallArgument {
|
||||
value: Some(serde_json::json!(delta_x)),
|
||||
object_id: None,
|
||||
},
|
||||
CallArgument {
|
||||
value: Some(serde_json::json!(delta_y)),
|
||||
object_id: None,
|
||||
},
|
||||
]),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let js = format!("window.scrollBy({}, {})", delta_x, delta_y);
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn select_option(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
values: &[String],
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let js = r#"function(vals) {
|
||||
const options = Array.from(this.options);
|
||||
for (const opt of options) {
|
||||
opt.selected = vals.includes(opt.value) || vals.includes(opt.textContent.trim());
|
||||
}
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}"#
|
||||
.to_string();
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: Some(vec![CallArgument {
|
||||
value: Some(serde_json::json!(values)),
|
||||
object_id: None,
|
||||
}]),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let is_checked =
|
||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||
if !is_checked {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uncheck(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let is_checked =
|
||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||
if is_checked {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn focus(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.focus();
|
||||
this.value = '';
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn select_all(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.focus();
|
||||
if (typeof this.select === 'function') {
|
||||
this.select();
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(this);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn scroll_into_view(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { this.scrollIntoView({ block: 'center', inline: 'center' }); }"
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dispatch_event(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
event_type: &str,
|
||||
event_init: Option<&Value>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let init_json = event_init
|
||||
.map(|v| serde_json::to_string(v).unwrap_or("{}".to_string()))
|
||||
.unwrap_or_else(|| "{ bubbles: true }".to_string());
|
||||
|
||||
let js = format!(
|
||||
"function() {{ this.dispatchEvent(new Event({}, {})); }}",
|
||||
serde_json::to_string(event_type).unwrap_or_default(),
|
||||
init_json
|
||||
);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn highlight(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.style.outline = '2px solid red';
|
||||
this.style.outlineOffset = '2px';
|
||||
const el = this;
|
||||
setTimeout(() => {
|
||||
el.style.outline = '';
|
||||
el.style.outlineOffset = '';
|
||||
}, 3000);
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn tap_touch(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Input.dispatchTouchEvent",
|
||||
Some(serde_json::json!({
|
||||
"type": "touchStart",
|
||||
"touchPoints": [{ "x": x, "y": y }],
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Input.dispatchTouchEvent",
|
||||
Some(serde_json::json!({
|
||||
"type": "touchEnd",
|
||||
"touchPoints": [],
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch_click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
button: &str,
|
||||
click_count: i32,
|
||||
) -> Result<(), String> {
|
||||
// Move
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseMoved".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: None,
|
||||
buttons: None,
|
||||
click_count: None,
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let button_value = match button {
|
||||
"right" => 2,
|
||||
"middle" => 4,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
// Press
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mousePressed".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: Some(button.to_string()),
|
||||
buttons: Some(button_value),
|
||||
click_count: Some(click_count),
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Release
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseReleased".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: Some(button.to_string()),
|
||||
buttons: Some(0),
|
||||
click_count: Some(click_count),
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn char_to_key_info(ch: char) -> (String, String, i32) {
|
||||
match ch {
|
||||
'\n' | '\r' => ("Enter".to_string(), "Enter".to_string(), 13),
|
||||
'\t' => ("Tab".to_string(), "Tab".to_string(), 9),
|
||||
' ' => (" ".to_string(), "Space".to_string(), 32),
|
||||
_ => {
|
||||
let key = ch.to_string();
|
||||
let code = if ch.is_ascii_alphabetic() {
|
||||
format!("Key{}", ch.to_uppercase())
|
||||
} else if ch.is_ascii_digit() {
|
||||
format!("Digit{}", ch)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let key_code = ch as i32;
|
||||
(key, code, key_code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn named_key_info(key: &str) -> (String, String, i32) {
|
||||
match key.to_lowercase().as_str() {
|
||||
"enter" | "return" => ("Enter".to_string(), "Enter".to_string(), 13),
|
||||
"tab" => ("Tab".to_string(), "Tab".to_string(), 9),
|
||||
"escape" | "esc" => ("Escape".to_string(), "Escape".to_string(), 27),
|
||||
"backspace" => ("Backspace".to_string(), "Backspace".to_string(), 8),
|
||||
"delete" => ("Delete".to_string(), "Delete".to_string(), 46),
|
||||
"arrowup" | "up" => ("ArrowUp".to_string(), "ArrowUp".to_string(), 38),
|
||||
"arrowdown" | "down" => ("ArrowDown".to_string(), "ArrowDown".to_string(), 40),
|
||||
"arrowleft" | "left" => ("ArrowLeft".to_string(), "ArrowLeft".to_string(), 37),
|
||||
"arrowright" | "right" => ("ArrowRight".to_string(), "ArrowRight".to_string(), 39),
|
||||
"home" => ("Home".to_string(), "Home".to_string(), 36),
|
||||
"end" => ("End".to_string(), "End".to_string(), 35),
|
||||
"pageup" => ("PageUp".to_string(), "PageUp".to_string(), 33),
|
||||
"pagedown" => ("PageDown".to_string(), "PageDown".to_string(), 34),
|
||||
"space" | " " => (" ".to_string(), "Space".to_string(), 32),
|
||||
_ => {
|
||||
if key.len() == 1 {
|
||||
let ch = key.chars().next().unwrap();
|
||||
char_to_key_info(ch)
|
||||
} else {
|
||||
(key.to_string(), key.to_string(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod actions;
|
||||
#[allow(dead_code)]
|
||||
pub mod auth;
|
||||
#[allow(dead_code)]
|
||||
pub mod browser;
|
||||
#[allow(dead_code)]
|
||||
pub mod cdp;
|
||||
#[allow(dead_code)]
|
||||
pub mod cookies;
|
||||
#[allow(dead_code)]
|
||||
pub mod daemon;
|
||||
#[allow(dead_code)]
|
||||
pub mod diff;
|
||||
#[allow(dead_code)]
|
||||
pub mod element;
|
||||
#[allow(dead_code)]
|
||||
pub mod interaction;
|
||||
#[allow(dead_code)]
|
||||
pub mod network;
|
||||
#[allow(dead_code)]
|
||||
pub mod policy;
|
||||
#[allow(dead_code)]
|
||||
pub mod providers;
|
||||
#[allow(dead_code)]
|
||||
pub mod recording;
|
||||
#[allow(dead_code)]
|
||||
pub mod screenshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod snapshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod state;
|
||||
#[allow(dead_code)]
|
||||
pub mod storage;
|
||||
#[allow(dead_code)]
|
||||
pub mod stream;
|
||||
#[allow(dead_code)]
|
||||
pub mod tracing;
|
||||
#[allow(dead_code)]
|
||||
pub mod webdriver;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e_tests;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
@@ -0,0 +1,399 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
pub async fn set_extra_headers(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let headers_value: Value = headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect::<serde_json::Map<String, Value>>()
|
||||
.into();
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Network.setExtraHTTPHeaders",
|
||||
Some(json!({ "headers": headers_value })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_offline(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
offline: bool,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Network.emulateNetworkConditions",
|
||||
Some(json!({
|
||||
"offline": offline,
|
||||
"latency": 0,
|
||||
"downloadThroughput": -1,
|
||||
"uploadThroughput": -1,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_content(client: &CdpClient, session_id: &str, html: &str) -> Result<(), String> {
|
||||
// Get current frame ID
|
||||
let tree_result = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let frame_id = tree_result
|
||||
.get("frameTree")
|
||||
.and_then(|t| t.get("frame"))
|
||||
.and_then(|f| f.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.ok_or("Could not determine frame ID")?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Page.setDocumentContent",
|
||||
Some(json!({
|
||||
"frameId": frame_id,
|
||||
"html": html,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DomainFilter {
|
||||
pub allowed_domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl DomainFilter {
|
||||
pub fn new(domains: &str) -> Self {
|
||||
let allowed = parse_domain_list(domains);
|
||||
Self {
|
||||
allowed_domains: allowed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_allowed(&self, hostname: &str) -> bool {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let hostname = hostname.to_lowercase();
|
||||
for pattern in &self.allowed_domains {
|
||||
if let Some(suffix) = pattern.strip_prefix("*.") {
|
||||
if hostname == suffix || hostname.ends_with(&format!(".{}", suffix)) {
|
||||
return true;
|
||||
}
|
||||
} else if hostname == *pattern {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn check_url(&self, url: &str) -> Result<(), String> {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parsed = url::Url::parse(url).map_err(|_| format!("Invalid URL: {}", url))?;
|
||||
let hostname = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| format!("No hostname in URL: {}", url))?;
|
||||
if self.is_allowed(hostname) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Domain '{}' is not in the allowed domains list",
|
||||
hostname
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_domain_list(input: &str) -> Vec<String> {
|
||||
input
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn sanitize_existing_pages(
|
||||
client: &CdpClient,
|
||||
pages: &[super::browser::PageInfo],
|
||||
filter: &DomainFilter,
|
||||
) {
|
||||
for page in pages {
|
||||
if page.url.is_empty() || page.url == "about:blank" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(parsed) = url::Url::parse(&page.url) {
|
||||
if let Some(hostname) = parsed.host_str() {
|
||||
if !filter.is_allowed(hostname) {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": "about:blank" })),
|
||||
Some(&page.session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_domain_filter_script(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
) -> Result<(), String> {
|
||||
if allowed_domains.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let domains_json = serde_json::to_string(allowed_domains).unwrap_or("[]".to_string());
|
||||
let script = format!(
|
||||
r#"(() => {{
|
||||
const _allowed = {};
|
||||
function _isDomainAllowed(hostname) {{
|
||||
hostname = hostname.toLowerCase();
|
||||
for (const p of _allowed) {{
|
||||
if (p.startsWith('*.')) {{
|
||||
const suffix = p.slice(2);
|
||||
if (hostname === suffix || hostname.endsWith('.' + suffix)) return true;
|
||||
}} else if (hostname === p) return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
const OrigWS = window.WebSocket;
|
||||
window.WebSocket = function(url, protocols) {{
|
||||
try {{
|
||||
const u = new URL(url);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('WebSocket blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigWS(url, protocols);
|
||||
}};
|
||||
window.WebSocket.prototype = OrigWS.prototype;
|
||||
const OrigES = window.EventSource;
|
||||
if (OrigES) {{
|
||||
window.EventSource = function(url, opts) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('EventSource blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigES(url, opts);
|
||||
}};
|
||||
window.EventSource.prototype = OrigES.prototype;
|
||||
}}
|
||||
const origBeacon = navigator.sendBeacon;
|
||||
if (origBeacon) {{
|
||||
navigator.sendBeacon = function(url, data) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) return false;
|
||||
}} catch(e) {{ return false; }}
|
||||
return origBeacon.call(navigator, url, data);
|
||||
}};
|
||||
}}
|
||||
}})()"#,
|
||||
domains_json,
|
||||
);
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable Fetch-based network interception for domain filtering.
|
||||
/// This intercepts all requests and checks them against the allowed domains list.
|
||||
/// The actual handling of `Fetch.requestPaused` events happens in
|
||||
/// `resolve_fetch_paused` in the actions module.
|
||||
pub async fn install_domain_filter_fetch(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Fetch.enable",
|
||||
Some(json!({
|
||||
"patterns": [{ "urlPattern": "*" }]
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install both layers of domain filtering on a session:
|
||||
/// 1. JS patching (WebSocket, EventSource, sendBeacon)
|
||||
/// 2. Fetch-based network interception
|
||||
pub async fn install_domain_filter(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
) -> Result<(), String> {
|
||||
install_domain_filter_script(client, session_id, allowed_domains).await?;
|
||||
install_domain_filter_fetch(client, session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console and error tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConsoleEntry {
|
||||
pub level: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErrorEntry {
|
||||
pub text: String,
|
||||
pub url: Option<String>,
|
||||
pub line: Option<i64>,
|
||||
pub column: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct EventTracker {
|
||||
pub console_entries: Vec<ConsoleEntry>,
|
||||
pub error_entries: Vec<ErrorEntry>,
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
impl EventTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
console_entries: Vec::new(),
|
||||
error_entries: Vec::new(),
|
||||
max_entries: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_console(&mut self, level: &str, text: &str) {
|
||||
if self.console_entries.len() >= self.max_entries {
|
||||
self.console_entries.remove(0);
|
||||
}
|
||||
self.console_entries.push(ConsoleEntry {
|
||||
level: level.to_string(),
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_error(
|
||||
&mut self,
|
||||
text: &str,
|
||||
url: Option<&str>,
|
||||
line: Option<i64>,
|
||||
col: Option<i64>,
|
||||
) {
|
||||
if self.error_entries.len() >= self.max_entries {
|
||||
self.error_entries.remove(0);
|
||||
}
|
||||
self.error_entries.push(ErrorEntry {
|
||||
text: text.to_string(),
|
||||
url: url.map(String::from),
|
||||
line,
|
||||
column: col,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get_console_json(&self) -> Value {
|
||||
let entries: Vec<Value> = self
|
||||
.console_entries
|
||||
.iter()
|
||||
.map(|e| json!({ "level": e.level, "text": e.text }))
|
||||
.collect();
|
||||
json!({ "entries": entries })
|
||||
}
|
||||
|
||||
pub fn get_errors_json(&self) -> Value {
|
||||
let entries: Vec<Value> = self
|
||||
.error_entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"text": e.text,
|
||||
"url": e.url,
|
||||
"line": e.line,
|
||||
"column": e.column,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "errors": entries })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_exact() {
|
||||
let filter = DomainFilter::new("example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_wildcard() {
|
||||
let filter = DomainFilter::new("*.example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(filter.is_allowed("api.example.com"));
|
||||
assert!(filter.is_allowed("sub.api.example.com"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_empty() {
|
||||
let filter = DomainFilter::new("");
|
||||
assert!(filter.is_allowed("anything.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_multiple() {
|
||||
let filter = DomainFilter::new("example.com, *.api.io");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(filter.is_allowed("api.io"));
|
||||
assert!(filter.is_allowed("v1.api.io"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_domain_list() {
|
||||
let domains = parse_domain_list("A.com, B.com , *.C.com");
|
||||
assert_eq!(domains, vec!["a.com", "b.com", "*.c.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_tracker() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "hello");
|
||||
tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
|
||||
|
||||
assert_eq!(tracker.console_entries.len(), 1);
|
||||
assert_eq!(tracker.error_entries.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
//! Parity tests for the native daemon's command interface.
|
||||
//!
|
||||
//! These unit tests verify:
|
||||
//! - All documented actions are handled (not returning "Not yet implemented")
|
||||
//! - Response format consistency (success/error structure)
|
||||
//! - Credential and state actions work without a browser
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
|
||||
/// All documented action names that should be implemented.
|
||||
const DOCUMENTED_ACTIONS: &[&str] = &[
|
||||
"launch",
|
||||
"navigate",
|
||||
"url",
|
||||
"title",
|
||||
"content",
|
||||
"evaluate",
|
||||
"close",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"click",
|
||||
"dblclick",
|
||||
"fill",
|
||||
"type",
|
||||
"press",
|
||||
"hover",
|
||||
"scroll",
|
||||
"select",
|
||||
"check",
|
||||
"uncheck",
|
||||
"wait",
|
||||
"gettext",
|
||||
"getattribute",
|
||||
"isvisible",
|
||||
"isenabled",
|
||||
"ischecked",
|
||||
"back",
|
||||
"forward",
|
||||
"reload",
|
||||
"cookies_get",
|
||||
"cookies_set",
|
||||
"cookies_clear",
|
||||
"storage_get",
|
||||
"storage_set",
|
||||
"storage_clear",
|
||||
"setcontent",
|
||||
"headers",
|
||||
"offline",
|
||||
"console",
|
||||
"errors",
|
||||
"state_save",
|
||||
"state_load",
|
||||
"state_list",
|
||||
"state_show",
|
||||
"state_clear",
|
||||
"state_clean",
|
||||
"state_rename",
|
||||
"trace_start",
|
||||
"trace_stop",
|
||||
"profiler_start",
|
||||
"profiler_stop",
|
||||
"recording_start",
|
||||
"recording_stop",
|
||||
"recording_restart",
|
||||
"pdf",
|
||||
"tab_list",
|
||||
"tab_new",
|
||||
"tab_switch",
|
||||
"tab_close",
|
||||
"viewport",
|
||||
"user_agent",
|
||||
"set_media",
|
||||
"download",
|
||||
"diff_snapshot",
|
||||
"diff_url",
|
||||
"credentials_set",
|
||||
"credentials_get",
|
||||
"credentials_delete",
|
||||
"credentials_list",
|
||||
"mouse",
|
||||
"keyboard",
|
||||
"focus",
|
||||
"clear",
|
||||
"selectall",
|
||||
"scrollintoview",
|
||||
"dispatch",
|
||||
"highlight",
|
||||
"tap",
|
||||
"boundingbox",
|
||||
"innertext",
|
||||
"innerhtml",
|
||||
"inputvalue",
|
||||
"setvalue",
|
||||
"count",
|
||||
"styles",
|
||||
"bringtofront",
|
||||
"timezone",
|
||||
"locale",
|
||||
"geolocation",
|
||||
"permissions",
|
||||
"dialog",
|
||||
"upload",
|
||||
"addscript",
|
||||
"addinitscript",
|
||||
"addstyle",
|
||||
"clipboard",
|
||||
"wheel",
|
||||
"device",
|
||||
"screencast_start",
|
||||
"screencast_stop",
|
||||
"waitforurl",
|
||||
"waitforloadstate",
|
||||
"waitforfunction",
|
||||
"frame",
|
||||
"mainframe",
|
||||
"getbyrole",
|
||||
"getbytext",
|
||||
"getbylabel",
|
||||
"getbyplaceholder",
|
||||
"getbyalttext",
|
||||
"getbytitle",
|
||||
"getbytestid",
|
||||
"nth",
|
||||
"find",
|
||||
"evalhandle",
|
||||
"drag",
|
||||
"expose",
|
||||
"pause",
|
||||
"multiselect",
|
||||
"responsebody",
|
||||
"waitfordownload",
|
||||
"window_new",
|
||||
"diff_screenshot",
|
||||
"video_start",
|
||||
"video_stop",
|
||||
"har_start",
|
||||
"har_stop",
|
||||
"route",
|
||||
"unroute",
|
||||
"requests",
|
||||
"credentials",
|
||||
"auth_save",
|
||||
"auth_login",
|
||||
"auth_list",
|
||||
"auth_delete",
|
||||
"auth_show",
|
||||
"confirm",
|
||||
"deny",
|
||||
"swipe",
|
||||
"device_list",
|
||||
"input_mouse",
|
||||
"input_keyboard",
|
||||
"input_touch",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"inserttext",
|
||||
"mousemove",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
];
|
||||
|
||||
fn minimal_command(action: &str, id: &str) -> Value {
|
||||
let mut cmd = json!({ "action": action, "id": id });
|
||||
let obj = cmd.as_object_mut().unwrap();
|
||||
|
||||
match action {
|
||||
"navigate" | "diff_url" | "waitforurl" => {
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"evaluate" | "expose" => {
|
||||
obj.insert("script".to_string(), json!("1"));
|
||||
}
|
||||
"click" | "dblclick" | "fill" | "type" | "press" | "hover" | "scroll" | "select"
|
||||
| "check" | "uncheck" | "gettext" | "getattribute" | "isvisible" | "isenabled"
|
||||
| "ischecked" | "focus" | "clear" | "selectall" | "scrollintoview" | "dispatch"
|
||||
| "highlight" | "tap" | "boundingbox" | "innertext" | "innerhtml" | "inputvalue"
|
||||
| "setvalue" | "count" | "find" | "nth" | "getbytext" | "getbylabel"
|
||||
| "getbyplaceholder" | "getbyalttext" | "getbytitle" | "getbytestid" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"getbyrole" => {
|
||||
obj.insert("role".to_string(), json!("button"));
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"setcontent" => {
|
||||
obj.insert("html".to_string(), json!("<html></html>"));
|
||||
}
|
||||
"cookies_set" => {
|
||||
obj.insert("name".to_string(), json!("test"));
|
||||
obj.insert("value".to_string(), json!("val"));
|
||||
}
|
||||
"storage_get" | "storage_set" | "storage_clear" => {
|
||||
obj.insert("origin".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"state_save" | "state_load" | "state_show" | "state_clear" => {
|
||||
obj.insert("path".to_string(), json!("test-parity-state.json"));
|
||||
}
|
||||
"state_rename" => {
|
||||
obj.insert("path".to_string(), json!("test-parity-state.json"));
|
||||
obj.insert("name".to_string(), json!("renamed"));
|
||||
}
|
||||
"state_clean" => {
|
||||
obj.insert("days".to_string(), json!(7));
|
||||
}
|
||||
"credentials_set" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"auth_save" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"credentials_get" | "credentials_delete" | "auth_show" | "auth_delete" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
}
|
||||
"tab_switch" | "tab_close" => {
|
||||
obj.insert("index".to_string(), json!(0));
|
||||
}
|
||||
"viewport" | "user_agent" | "set_media" | "timezone" | "locale" | "geolocation"
|
||||
| "permissions" | "device" => {
|
||||
obj.insert("value".to_string(), json!(null));
|
||||
}
|
||||
"headers" => {
|
||||
obj.insert("headers".to_string(), json!({}));
|
||||
}
|
||||
"offline" => {
|
||||
obj.insert("offline".to_string(), json!(false));
|
||||
}
|
||||
"wait" => {
|
||||
obj.insert("timeout".to_string(), json!(100));
|
||||
}
|
||||
"waitforloadstate" => {
|
||||
obj.insert("state".to_string(), json!("load"));
|
||||
}
|
||||
"waitforfunction" => {
|
||||
obj.insert("script".to_string(), json!("() => true"));
|
||||
}
|
||||
"frame" => {
|
||||
obj.insert("selector".to_string(), json!("iframe"));
|
||||
}
|
||||
"addscript" => {
|
||||
obj.insert("content".to_string(), json!("console.log('test')"));
|
||||
}
|
||||
"addinitscript" => {
|
||||
obj.insert("script".to_string(), json!("console.log('init')"));
|
||||
}
|
||||
"addstyle" => {
|
||||
obj.insert("content".to_string(), json!("body { color: red }"));
|
||||
}
|
||||
"wheel" => {
|
||||
obj.insert("deltaX".to_string(), json!(0));
|
||||
obj.insert("deltaY".to_string(), json!(0));
|
||||
}
|
||||
"upload" => {
|
||||
obj.insert("selector".to_string(), json!("input[type=file]"));
|
||||
obj.insert("files".to_string(), json!([]));
|
||||
}
|
||||
"dialog" => {
|
||||
obj.insert("accept".to_string(), json!(true));
|
||||
}
|
||||
"credentials" => {
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"auth_login" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
}
|
||||
"route" => {
|
||||
obj.insert("url".to_string(), json!("*"));
|
||||
obj.insert("handler".to_string(), json!("continue"));
|
||||
}
|
||||
"diff_snapshot" | "diff_screenshot" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"recording_start" | "recording_restart" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-recording.webm"));
|
||||
}
|
||||
"video_start" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-video.webm"));
|
||||
}
|
||||
"profiler_start" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-profile"));
|
||||
}
|
||||
"trace_stop" | "har_stop" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-trace"));
|
||||
}
|
||||
"download" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-download"));
|
||||
}
|
||||
"multiselect" => {
|
||||
obj.insert("selector".to_string(), json!("select"));
|
||||
obj.insert("values".to_string(), json!([]));
|
||||
}
|
||||
"responsebody" => {
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"waitfordownload" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-download"));
|
||||
}
|
||||
"styles" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("names".to_string(), json!([]));
|
||||
}
|
||||
"evalhandle" => {
|
||||
obj.insert("handle".to_string(), json!(""));
|
||||
obj.insert("script".to_string(), json!("h => h"));
|
||||
}
|
||||
"drag" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("target".to_string(), json!("body"));
|
||||
}
|
||||
"swipe" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("direction".to_string(), json!("left"));
|
||||
}
|
||||
"input_mouse" | "mousemove" | "mousedown" | "mouseup" => {
|
||||
obj.insert("x".to_string(), json!(100));
|
||||
obj.insert("y".to_string(), json!(100));
|
||||
}
|
||||
"input_keyboard" | "keydown" | "keyup" => {
|
||||
obj.insert("key".to_string(), json!("a"));
|
||||
}
|
||||
"input_touch" => {
|
||||
obj.insert("type".to_string(), json!("touchStart"));
|
||||
obj.insert("touchPoints".to_string(), json!([]));
|
||||
}
|
||||
"inserttext" => {
|
||||
obj.insert("text".to_string(), json!("test"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Action dispatch coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_documented_actions_are_handled() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
|
||||
let id = format!("parity-{}", i);
|
||||
let cmd = minimal_command(action, &id);
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert!(
|
||||
result.get("id").is_some(),
|
||||
"Action '{}': response missing 'id'",
|
||||
action
|
||||
);
|
||||
|
||||
let error = result.get("error").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
assert!(
|
||||
!error.contains("Not yet implemented"),
|
||||
"Action '{}' returned 'Not yet implemented')",
|
||||
action
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response format consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_success_response_format() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_list", "id": "fmt-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result.get("id").is_some());
|
||||
assert!(result.get("data").is_some());
|
||||
assert!(result.get("error").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_response_format() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "nonexistent_action_xyz", "id": "fmt-2" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], false);
|
||||
assert!(result.get("id").is_some());
|
||||
assert!(result.get("error").is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Credential/state actions work without a browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_list_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_list", "id": "nb-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result["data"]["files"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_list_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "credentials_list", "id": "nb-2" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result["data"]["credentials"].is_array() || result["data"]["profiles"].is_array());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. New feature parity tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_profile_name_validation() {
|
||||
use super::auth;
|
||||
let valid = auth::credentials_set("valid-name_123", "u", "p", None);
|
||||
assert!(valid.is_ok());
|
||||
let invalid = auth::credentials_set("invalid/name", "u", "p", None);
|
||||
assert!(invalid.is_err());
|
||||
let invalid2 = auth::credentials_set("", "u", "p", None);
|
||||
assert!(invalid2.is_err());
|
||||
let invalid3 = auth::credentials_set("has space", "u", "p", None);
|
||||
assert!(invalid3.is_err());
|
||||
// Cleanup
|
||||
let _ = auth::credentials_delete("valid-name_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_save_and_show() {
|
||||
use super::auth;
|
||||
let result = auth::auth_save(
|
||||
"parity-roundtrip",
|
||||
"https://example.com",
|
||||
"user",
|
||||
"pass",
|
||||
Some("input#user"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let show = auth::auth_show("parity-roundtrip");
|
||||
assert!(show.is_ok());
|
||||
let data = show.unwrap();
|
||||
assert_eq!(data["profile"]["username"], "user");
|
||||
assert_eq!(data["profile"]["usernameSelector"], "input#user");
|
||||
|
||||
let full = auth::credentials_get_full("parity-roundtrip");
|
||||
assert!(full.is_ok());
|
||||
assert_eq!(full.unwrap().password, "pass");
|
||||
|
||||
// Cleanup
|
||||
let _ = auth::credentials_delete("parity-roundtrip");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_har_start_stop_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
// har_start requires a browser. Because execute_command auto-launches when
|
||||
// no browser is present, the result depends on Chrome availability: success
|
||||
// if Chrome is found (CI), failure if not. Both outcomes are valid.
|
||||
let cmd = json!({ "action": "har_start", "id": "har-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
let success = result["success"].as_bool().unwrap_or(false);
|
||||
if success {
|
||||
assert!(state.har_recording);
|
||||
} else {
|
||||
assert!(result["error"].as_str().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_clean_action() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_clean", "id": "clean-1", "days": 30 });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
assert_eq!(result["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_daemon_state_new_defaults() {
|
||||
let state = DaemonState::new();
|
||||
assert!(state.browser.is_none());
|
||||
assert!(!state.har_recording);
|
||||
assert!(state.har_entries.is_empty());
|
||||
assert!(state.pending_confirmation.is_none());
|
||||
assert!(!state.request_tracking);
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
assert!(state.active_frame_id.is_none());
|
||||
assert!(state.webdriver_backend.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tracked_request_struct() {
|
||||
use super::actions::TrackedRequest;
|
||||
let tr = TrackedRequest {
|
||||
url: "https://example.com/api".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: json!({"Accept": "text/html"}),
|
||||
timestamp: 12345,
|
||||
resource_type: "Document".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_value(&tr).unwrap();
|
||||
assert_eq!(serialized["url"], "https://example.com/api");
|
||||
assert_eq!(serialized["method"], "GET");
|
||||
assert_eq!(serialized["resourceType"], "Document");
|
||||
assert_eq!(serialized["timestamp"], 12345);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_tracking_state() {
|
||||
let mut state = DaemonState::new();
|
||||
assert!(!state.request_tracking);
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://example.com".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: json!({}),
|
||||
timestamp: 1,
|
||||
resource_type: "Document".to_string(),
|
||||
});
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://other.com".to_string(),
|
||||
method: "POST".to_string(),
|
||||
headers: json!({}),
|
||||
timestamp: 2,
|
||||
resource_type: "XHR".to_string(),
|
||||
});
|
||||
assert_eq!(state.tracked_requests.len(), 2);
|
||||
|
||||
// Filter
|
||||
let filtered: Vec<_> = state
|
||||
.tracked_requests
|
||||
.iter()
|
||||
.filter(|r| r.url.contains("example"))
|
||||
.collect();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].url, "https://example.com");
|
||||
|
||||
// Clear
|
||||
state.tracked_requests.clear();
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addscript_and_addinitscript_separate_dispatch() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Both should be handled (not "Not yet implemented") even without a browser
|
||||
let cmd1 = json!({ "action": "addscript", "id": "as-1", "content": "console.log(1)" });
|
||||
let result1 = execute_command(&cmd1, &mut state).await;
|
||||
let err1 = result1["error"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!err1.contains("Not yet implemented"),
|
||||
"addscript should be handled"
|
||||
);
|
||||
|
||||
let cmd2 = json!({ "action": "addinitscript", "id": "ais-1", "script": "console.log(2)" });
|
||||
let result2 = execute_command(&cmd2, &mut state).await;
|
||||
let err2 = result2["error"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!err2.contains("Not yet implemented"),
|
||||
"addinitscript should be handled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_frame_context_management() {
|
||||
let mut state = DaemonState::new();
|
||||
assert!(state.active_frame_id.is_none());
|
||||
|
||||
// Set a frame ID and verify it persists
|
||||
state.active_frame_id = Some("child-frame-123".to_string());
|
||||
assert_eq!(state.active_frame_id.as_deref(), Some("child-frame-123"));
|
||||
|
||||
// Clearing the frame ID (what mainframe does)
|
||||
state.active_frame_id = None;
|
||||
assert!(state.active_frame_id.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addstyle_supports_content_and_url() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Both content-based and url-based addstyle should be recognized
|
||||
let cmd1 = json!({ "action": "addstyle", "id": "style-1", "content": "body { color: red }" });
|
||||
let result1 = execute_command(&cmd1, &mut state).await;
|
||||
let err1 = result1["error"].as_str().unwrap_or("");
|
||||
assert!(!err1.contains("Not yet implemented"));
|
||||
|
||||
let cmd2 =
|
||||
json!({ "action": "addstyle", "id": "style-2", "url": "https://example.com/style.css" });
|
||||
let result2 = execute_command(&cmd2, &mut state).await;
|
||||
let err2 = result2["error"].as_str().unwrap_or("");
|
||||
assert!(!err2.contains("Not yet implemented"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_domain_filter_sanitize() {
|
||||
use super::network::DomainFilter;
|
||||
let filter = DomainFilter::new("example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(!filter.is_allowed("evil.com"));
|
||||
filter.check_url("https://example.com/path").unwrap();
|
||||
assert!(filter.check_url("https://evil.com").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_find_auto_returns_none_for_nonexistent() {
|
||||
use super::state;
|
||||
let result = state::find_auto_state_file("nonexistent-session-xyz");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Result of a policy check for an action.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PolicyResult {
|
||||
/// Action is allowed.
|
||||
Allow,
|
||||
/// Action is blocked with the given reason.
|
||||
Deny(String),
|
||||
/// Action requires confirmation before proceeding.
|
||||
RequiresConfirmation,
|
||||
}
|
||||
|
||||
/// Policy configuration loaded from a JSON file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionPolicy {
|
||||
#[serde(skip)]
|
||||
path: PathBuf,
|
||||
#[serde(default)]
|
||||
default: Option<String>,
|
||||
#[serde(default)]
|
||||
allow: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
deny: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
confirm: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Confirmation categories parsed from AGENT_BROWSER_CONFIRM_ACTIONS.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfirmActions {
|
||||
pub categories: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ConfirmActions {
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let val = env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()?;
|
||||
if val.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let categories: HashSet<String> = val
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if categories.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self { categories })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_confirmation(&self, action: &str) -> bool {
|
||||
self.categories.contains(action)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionPolicy {
|
||||
/// Load policy from a JSON file at the given path.
|
||||
pub fn load(path: &str) -> Result<Self, String> {
|
||||
let path_buf = PathBuf::from(path);
|
||||
let contents = fs::read_to_string(&path_buf)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = path_buf;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// Load policy if AGENT_BROWSER_ACTION_POLICY env var is set.
|
||||
/// Falls back to AGENT_BROWSER_POLICY for backwards compatibility.
|
||||
pub fn load_if_exists() -> Option<Self> {
|
||||
let path = env::var("AGENT_BROWSER_ACTION_POLICY")
|
||||
.or_else(|_| env::var("AGENT_BROWSER_POLICY"))
|
||||
.ok()?;
|
||||
Self::load(&path).ok()
|
||||
}
|
||||
|
||||
/// Check whether an action is allowed, denied, or requires confirmation.
|
||||
pub fn check(&self, action: &str) -> PolicyResult {
|
||||
if let Some(deny) = &self.deny {
|
||||
if deny.iter().any(|a| a == action) {
|
||||
return PolicyResult::Deny(format!("Action '{}' is denied by policy", action));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(confirm) = &self.confirm {
|
||||
if confirm.iter().any(|a| a == action) {
|
||||
return PolicyResult::RequiresConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(allow) = &self.allow {
|
||||
if !allow.is_empty() && !allow.iter().any(|a| a == action) {
|
||||
let is_default_deny = self
|
||||
.default
|
||||
.as_deref()
|
||||
.map(|d| d.eq_ignore_ascii_case("deny"))
|
||||
.unwrap_or(true);
|
||||
if is_default_deny {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' is not in the allow list",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(ref default) = self.default {
|
||||
if default.eq_ignore_ascii_case("deny") {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' denied: default policy is deny",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
PolicyResult::Allow
|
||||
}
|
||||
|
||||
/// Reload policy from the file. Re-reads the JSON and updates the policy.
|
||||
pub fn reload(&mut self) -> Result<(), String> {
|
||||
let contents = fs::read_to_string(&self.path)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = self.path.clone();
|
||||
*self = policy;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_policy_allow_whitelist() {
|
||||
let json = r#"{"allow": ["click", "type"], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert_eq!(policy.check("type"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny() {
|
||||
let json = r#"{"allow": [], "deny": ["delete"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("delete"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny_takes_precedence() {
|
||||
let json = r#"{"allow": ["danger"], "deny": ["danger"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("danger"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm_takes_precedence_over_allow() {
|
||||
let json = r#"{"allow": ["submit"], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_empty_allow_allows_all() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_missing_allow_allows_all() {
|
||||
let json = r#"{"deny": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_allow() {
|
||||
let json = r#"{"default": "allow", "deny": ["navigate"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_deny() {
|
||||
let json = r#"{"default": "deny", "allow": ["click"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confirm_actions_from_env() {
|
||||
env::set_var("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let ca = ConfirmActions::from_env().unwrap();
|
||||
assert!(ca.requires_confirmation("navigate"));
|
||||
assert!(ca.requires_confirmation("click"));
|
||||
assert!(ca.requires_confirmation("fill"));
|
||||
assert!(!ca.requires_confirmation("screenshot"));
|
||||
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Browser provider connections for remote CDP sessions.
|
||||
//!
|
||||
//! Supports Browserbase, Browser Use, and Kernel providers. Each provider
|
||||
//! returns a CDP WebSocket URL for connecting via BrowserManager.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
|
||||
/// Provider session info for cleanup on failure.
|
||||
pub struct ProviderSession {
|
||||
pub provider: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Connects to the specified browser provider and returns a CDP WebSocket URL
|
||||
/// along with session info for cleanup on failure.
|
||||
pub async fn connect_provider(
|
||||
provider_name: &str,
|
||||
) -> Result<(String, Option<ProviderSession>), String> {
|
||||
match provider_name.to_lowercase().as_str() {
|
||||
"browserbase" => connect_browserbase().await,
|
||||
"browser-use" | "browseruse" => connect_browser_use().await,
|
||||
"kernel" => connect_kernel().await,
|
||||
_ => Err(format!(
|
||||
"Unknown provider '{}'. Supported: browserbase, browser-use, kernel",
|
||||
provider_name
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a provider session (call on CDP connect failure).
|
||||
pub async fn close_provider_session(session: &ProviderSession) {
|
||||
let client = reqwest::Client::new();
|
||||
match session.provider.as_str() {
|
||||
"browserbase" => {
|
||||
if let Ok(api_key) = env::var("BROWSERBASE_API_KEY") {
|
||||
let _ = client
|
||||
.delete(format!(
|
||||
"https://api.browserbase.com/v1/sessions/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"browser-use" => {
|
||||
if let Ok(api_key) = env::var("BROWSER_USE_API_KEY") {
|
||||
let _ = client
|
||||
.patch(format!(
|
||||
"https://api.browser-use.com/api/v2/browsers/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("X-Browser-Use-API-Key", &api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({ "action": "stop" }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"kernel" => {
|
||||
if let Ok(api_key) = env::var("KERNEL_API_KEY") {
|
||||
let endpoint = env::var("KERNEL_ENDPOINT")
|
||||
.unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
let _ = client
|
||||
.delete(format!(
|
||||
"{}/browsers/{}",
|
||||
endpoint.trim_end_matches('/'),
|
||||
session.session_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERBASE_API_KEY")
|
||||
.map_err(|_| "BROWSERBASE_API_KEY environment variable is not set")?;
|
||||
let project_id = env::var("BROWSERBASE_PROJECT_ID")
|
||||
.map_err(|_| "BROWSERBASE_PROJECT_ID environment variable is not set")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browserbase.com/v1/sessions")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.json(&json!({ "projectId": project_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserbase request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browserbase response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browserbase API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserbase response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("connectUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserbase response missing connectUrl".to_string())?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browserbase".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSER_USE_API_KEY")
|
||||
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browser-use.com/api/v2/browsers")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Browser-Use-API-Key", &api_key)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browser Use request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browser Use response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browser Use API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browser Use response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("cdp_url")
|
||||
.or_else(|| json.get("cdpUrl"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browser Use response missing cdp_url or cdpUrl".to_string())?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browser-use".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key =
|
||||
env::var("KERNEL_API_KEY").map_err(|_| "KERNEL_API_KEY environment variable is not set")?;
|
||||
let endpoint =
|
||||
env::var("KERNEL_ENDPOINT").unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
|
||||
let url = format!("{}/browsers", endpoint.trim_end_matches('/'));
|
||||
|
||||
let headless = env::var("KERNEL_HEADLESS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
let stealth = env::var("KERNEL_STEALTH")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let timeout_seconds = env::var("KERNEL_TIMEOUT_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(300);
|
||||
|
||||
let mut body = json!({
|
||||
"headless": headless,
|
||||
"stealth": stealth,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
});
|
||||
|
||||
if let Ok(profile) = env::var("KERNEL_PROFILE_NAME") {
|
||||
if !profile.is_empty() {
|
||||
body.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("profile".to_string(), json!(profile));
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Kernel request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Kernel response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Kernel API error ({}): {}",
|
||||
status.as_u16(),
|
||||
resp_body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid Kernel response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("session_id")
|
||||
.or_else(|| json.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("cdp_ws_url")
|
||||
.or_else(|| json.get("connectUrl"))
|
||||
.or_else(|| json.get("connect_url"))
|
||||
.or_else(|| json.get("cdpUrl"))
|
||||
.or_else(|| json.get("cdp_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| {
|
||||
"Kernel response missing cdp_ws_url, connectUrl, connect_url, cdpUrl, or cdp_url"
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "kernel".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct RecordingState {
|
||||
pub active: bool,
|
||||
pub output_path: String,
|
||||
pub temp_dir: PathBuf,
|
||||
pub frame_count: u64,
|
||||
}
|
||||
|
||||
impl RecordingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
output_path: String::new(),
|
||||
temp_dir: PathBuf::new(),
|
||||
frame_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
if state.active {
|
||||
return Err("Recording already active".to_string());
|
||||
}
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
|
||||
let temp_dir = std::env::temp_dir().join(format!("agent-browser-recording-{}", timestamp));
|
||||
let _ = std::fs::create_dir_all(&temp_dir);
|
||||
|
||||
state.active = true;
|
||||
state.output_path = path.to_string();
|
||||
state.temp_dir = temp_dir;
|
||||
state.frame_count = 0;
|
||||
|
||||
Ok(json!({ "started": true, "path": path }))
|
||||
}
|
||||
|
||||
pub fn recording_add_frame(state: &mut RecordingState, frame_data: &[u8]) {
|
||||
if !state.active {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame_path = state
|
||||
.temp_dir
|
||||
.join(format!("frame_{:06}.jpg", state.frame_count));
|
||||
let _ = std::fs::write(&frame_path, frame_data);
|
||||
state.frame_count += 1;
|
||||
}
|
||||
|
||||
pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
|
||||
if !state.active {
|
||||
return Err("No recording in progress".to_string());
|
||||
}
|
||||
|
||||
state.active = false;
|
||||
|
||||
if state.frame_count == 0 {
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
return Err("No frames captured".to_string());
|
||||
}
|
||||
|
||||
let frame_pattern = state
|
||||
.temp_dir
|
||||
.join("frame_%06d.jpg")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let output = &state.output_path;
|
||||
|
||||
// Encode with ffmpeg
|
||||
let result = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-framerate",
|
||||
"30",
|
||||
"-i",
|
||||
&frame_pattern,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-preset",
|
||||
"fast",
|
||||
output,
|
||||
])
|
||||
.output();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
|
||||
match result {
|
||||
Ok(output_result) => {
|
||||
if output_result.status.success() {
|
||||
Ok(json!({ "path": output, "frames": state.frame_count }))
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output_result.stderr);
|
||||
Err(format!(
|
||||
"ffmpeg failed: {}",
|
||||
stderr.chars().take(200).collect::<String>()
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
|
||||
e
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_recording_state_new() {
|
||||
let state = RecordingState::new();
|
||||
assert!(!state.active);
|
||||
assert!(state.output_path.is_empty());
|
||||
assert_eq!(state.frame_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_sets_active() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_start(&mut state, "/tmp/test.mp4");
|
||||
assert!(result.is_ok());
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/test.mp4");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
|
||||
let temp_dir = state.temp_dir.clone();
|
||||
let result = recording_start(&mut state, "/tmp/test2.mp4");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("already active"));
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stop_not_active() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_stop(&mut state);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No recording"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stop_no_frames() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test.mp4").unwrap();
|
||||
let result = recording_stop(&mut state);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No frames"));
|
||||
assert!(!state.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_add_frame_inactive() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_add_frame(&mut state, b"fake-frame");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_add_frame_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test.mp4").unwrap();
|
||||
recording_add_frame(&mut state, b"fake-frame-1");
|
||||
recording_add_frame(&mut state, b"fake-frame-2");
|
||||
assert_eq!(state.frame_count, 2);
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
let previous = if state.active {
|
||||
let stop_result = recording_stop(state);
|
||||
stop_result
|
||||
.ok()
|
||||
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
recording_start(state, path)?;
|
||||
|
||||
Ok(json!({
|
||||
"restarted": true,
|
||||
"previousPath": previous,
|
||||
"path": path,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::RefMap;
|
||||
|
||||
pub struct ScreenshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub full_page: bool,
|
||||
pub format: String,
|
||||
pub quality: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
path: None,
|
||||
full_page: false,
|
||||
format: "png".to_string(),
|
||||
quality: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn take_screenshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
) -> Result<(String, String), String> {
|
||||
let mut params = CaptureScreenshotParams {
|
||||
format: Some(options.format.clone()),
|
||||
quality: if options.format == "jpeg" {
|
||||
options.quality.or(Some(80))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
clip: None,
|
||||
from_surface: Some(true),
|
||||
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
|
||||
};
|
||||
|
||||
if options.full_page {
|
||||
let metrics: Value = client
|
||||
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let content_size = metrics
|
||||
.get("contentSize")
|
||||
.or_else(|| metrics.get("cssContentSize"));
|
||||
if let Some(size) = content_size {
|
||||
let width = size.get("width").and_then(|v| v.as_f64()).unwrap_or(1280.0);
|
||||
let height = size.get("height").and_then(|v| v.as_f64()).unwrap_or(720.0);
|
||||
|
||||
params.clip = Some(Viewport {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width,
|
||||
height,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
} else if let Some(ref selector) = options.selector {
|
||||
// Element screenshot via bounding box
|
||||
let object_id =
|
||||
super::element::resolve_element_object_id(client, session_id, ref_map, selector)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(rect) = result.result.value {
|
||||
let x = rect.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = rect.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let w = rect.get("width").and_then(|v| v.as_f64()).unwrap_or(100.0);
|
||||
let h = rect.get("height").and_then(|v| v.as_f64()).unwrap_or(100.0);
|
||||
|
||||
params.clip = Some(Viewport {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result: CaptureScreenshotResult = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(session_id))
|
||||
.await?;
|
||||
|
||||
let ext = if options.format == "jpeg" {
|
||||
"jpg"
|
||||
} else {
|
||||
"png"
|
||||
};
|
||||
|
||||
let save_path = match &options.path {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
let dir = get_screenshot_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let name = format!("screenshot-{}.{}", timestamp, ext);
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &result.data)
|
||||
.map_err(|e| format!("Failed to decode screenshot: {}", e))?;
|
||||
|
||||
std::fs::write(&save_path, &bytes)
|
||||
.map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
|
||||
|
||||
Ok((save_path, result.data))
|
||||
}
|
||||
|
||||
fn get_screenshot_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("screenshots")
|
||||
} else {
|
||||
std::env::temp_dir()
|
||||
.join("agent-browser")
|
||||
.join("screenshots")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{
|
||||
AXNode, AXProperty, AXValue, CallFunctionOnParams, EvaluateParams, EvaluateResult,
|
||||
GetFullAXTreeResult,
|
||||
};
|
||||
use super::element::RefMap;
|
||||
|
||||
const INTERACTIVE_ROLES: &[&str] = &[
|
||||
"button",
|
||||
"link",
|
||||
"textbox",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"combobox",
|
||||
"listbox",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"option",
|
||||
"searchbox",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"switch",
|
||||
"tab",
|
||||
"treeitem",
|
||||
];
|
||||
|
||||
const CONTENT_ROLES: &[&str] = &[
|
||||
"heading",
|
||||
"cell",
|
||||
"gridcell",
|
||||
"columnheader",
|
||||
"rowheader",
|
||||
"listitem",
|
||||
"article",
|
||||
"region",
|
||||
"main",
|
||||
"navigation",
|
||||
];
|
||||
|
||||
const STRUCTURAL_ROLES: &[&str] = &[
|
||||
"generic",
|
||||
"group",
|
||||
"list",
|
||||
"table",
|
||||
"row",
|
||||
"rowgroup",
|
||||
"grid",
|
||||
"treegrid",
|
||||
"menu",
|
||||
"menubar",
|
||||
"toolbar",
|
||||
"tablist",
|
||||
"tree",
|
||||
"directory",
|
||||
"document",
|
||||
"application",
|
||||
"presentation",
|
||||
"none",
|
||||
"WebArea",
|
||||
"RootWebArea",
|
||||
];
|
||||
|
||||
pub struct SnapshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub interactive: bool,
|
||||
pub compact: bool,
|
||||
pub depth: Option<usize>,
|
||||
pub cursor: bool,
|
||||
}
|
||||
|
||||
impl Default for SnapshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
interactive: false,
|
||||
compact: false,
|
||||
depth: None,
|
||||
cursor: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TreeNode {
|
||||
role: String,
|
||||
name: String,
|
||||
level: Option<i64>,
|
||||
checked: Option<String>,
|
||||
expanded: Option<bool>,
|
||||
selected: Option<bool>,
|
||||
disabled: Option<bool>,
|
||||
required: Option<bool>,
|
||||
value_text: Option<String>,
|
||||
backend_node_id: Option<i64>,
|
||||
children: Vec<usize>,
|
||||
has_ref: bool,
|
||||
ref_id: Option<String>,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
struct RoleNameTracker {
|
||||
counts: HashMap<String, usize>,
|
||||
entries: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
impl RoleNameTracker {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
counts: HashMap::new(),
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn track(&mut self, role: &str, name: &str, node_idx: usize) -> usize {
|
||||
let key = format!("{}:{}", role, name);
|
||||
let count = self.counts.entry(key.clone()).or_insert(0);
|
||||
let nth = *count;
|
||||
*count += 1;
|
||||
self.entries.push((node_idx, key));
|
||||
nth
|
||||
}
|
||||
|
||||
fn get_duplicates(&self) -> HashMap<String, usize> {
|
||||
self.counts
|
||||
.iter()
|
||||
.filter(|(_, &count)| count > 1)
|
||||
.map(|(key, &count)| (key.clone(), count))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn take_snapshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
options: &SnapshotOptions,
|
||||
ref_map: &mut RefMap,
|
||||
) -> Result<String, String> {
|
||||
client
|
||||
.send_command_no_params("DOM.enable", Some(session_id))
|
||||
.await?;
|
||||
client
|
||||
.send_command_no_params("Accessibility.enable", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let ax_tree: GetFullAXTreeResult = client
|
||||
.send_command_typed(
|
||||
"Accessibility.getFullAXTree",
|
||||
&serde_json::json!({}),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
|
||||
|
||||
let mut tracker = RoleNameTracker::new();
|
||||
let mut next_ref: usize = ref_map.next_ref_num();
|
||||
|
||||
let mut nodes_with_refs: Vec<(usize, usize)> = Vec::new();
|
||||
|
||||
for (idx, node) in tree_nodes.iter().enumerate() {
|
||||
let role = node.role.as_str();
|
||||
let should_ref = if INTERACTIVE_ROLES.contains(&role) {
|
||||
true
|
||||
} else if CONTENT_ROLES.contains(&role) {
|
||||
!node.name.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if should_ref {
|
||||
let nth = tracker.track(role, &node.name, idx);
|
||||
nodes_with_refs.push((idx, nth));
|
||||
}
|
||||
}
|
||||
|
||||
let duplicates = tracker.get_duplicates();
|
||||
|
||||
let mut tree_nodes = tree_nodes;
|
||||
for (idx, nth) in &nodes_with_refs {
|
||||
let node = &tree_nodes[*idx];
|
||||
let key = format!("{}:{}", node.role, node.name);
|
||||
let actual_nth = if duplicates.contains_key(&key) {
|
||||
Some(*nth)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let ref_id = format!("e{}", next_ref);
|
||||
next_ref += 1;
|
||||
|
||||
ref_map.add(
|
||||
ref_id.clone(),
|
||||
tree_nodes[*idx].backend_node_id,
|
||||
&tree_nodes[*idx].role,
|
||||
&tree_nodes[*idx].name,
|
||||
actual_nth,
|
||||
);
|
||||
|
||||
tree_nodes[*idx].has_ref = true;
|
||||
tree_nodes[*idx].ref_id = Some(ref_id);
|
||||
}
|
||||
|
||||
ref_map.set_next_ref_num(next_ref);
|
||||
|
||||
let mut output = String::new();
|
||||
for &root_idx in &root_indices {
|
||||
render_tree(&tree_nodes, root_idx, 0, &mut output, options);
|
||||
}
|
||||
|
||||
if options.compact {
|
||||
output = compact_tree(&output, options.interactive);
|
||||
}
|
||||
|
||||
let mut trimmed = output.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
if options.interactive {
|
||||
return Ok("(no interactive elements)".to_string());
|
||||
}
|
||||
return Ok("(empty page)".to_string());
|
||||
}
|
||||
|
||||
if options.cursor {
|
||||
let cursor_section = find_cursor_interactive_elements(client, session_id, ref_map).await?;
|
||||
if !cursor_section.is_empty() {
|
||||
trimmed.push_str("\n# Cursor-interactive elements:\n");
|
||||
trimmed.push_str(&cursor_section);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
async fn find_cursor_interactive_elements(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &mut RefMap,
|
||||
) -> Result<String, String> {
|
||||
let js = r#"
|
||||
(function() {
|
||||
const elements = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
if (node.closest && node.closest('[hidden], [aria-hidden="true"]')) continue;
|
||||
const explicitRole = node.getAttribute ? node.getAttribute('role') : null;
|
||||
if (explicitRole) continue;
|
||||
const tag = node.tagName ? node.tagName.toLowerCase() : '';
|
||||
const hasClick = node.onclick || (node.attributes && node.attributes.getNamedItem('onclick'));
|
||||
const tabindex = node.getAttribute ? node.getAttribute('tabindex') : null;
|
||||
const contentEditable = node.getAttribute ? node.getAttribute('contenteditable') : null;
|
||||
const isInherentlyClickable =
|
||||
(tag === 'a' && node.href) || tag === 'button' ||
|
||||
(tag === 'input' && ['submit','button','image','reset'].indexOf((node.type||'').toLowerCase()) >= 0) ||
|
||||
tag === 'summary';
|
||||
const isFocusable = tabindex !== null && parseInt(tabindex, 10) >= 0;
|
||||
const isEditable = contentEditable === '' || contentEditable === 'true';
|
||||
if (hasClick || isInherentlyClickable || isFocusable || isEditable) {
|
||||
elements.push(node);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
})()
|
||||
"#;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js.to_string(),
|
||||
return_by_value: Some(false),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let array_object_id = match result.result.object_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(String::new()),
|
||||
};
|
||||
|
||||
let props_result: Value = client
|
||||
.send_command(
|
||||
"Runtime.getProperties",
|
||||
Some(serde_json::json!({ "objectId": array_object_id })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let empty: Vec<Value> = Vec::new();
|
||||
let result_array = props_result
|
||||
.get("result")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty);
|
||||
|
||||
let mut indexed: Vec<(usize, String)> = Vec::new();
|
||||
for prop in result_array {
|
||||
let name = prop.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Ok(idx) = name.parse::<usize>() {
|
||||
if let Some(obj_id) = prop
|
||||
.get("value")
|
||||
.and_then(|v| v.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
indexed.push((idx, obj_id.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
indexed.sort_by_key(|(idx, _)| *idx);
|
||||
let element_object_ids: Vec<String> = indexed.into_iter().map(|(_, id)| id).collect();
|
||||
|
||||
let mut next_ref = ref_map.next_ref_num();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let get_text_js =
|
||||
r#"function(){ return (this.innerText || this.textContent || '').trim().slice(0, 100) }"#;
|
||||
|
||||
for object_id in &element_object_ids {
|
||||
let describe: Value = client
|
||||
.send_command(
|
||||
"DOM.describeNode",
|
||||
Some(serde_json::json!({ "objectId": object_id })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let backend_node_id = describe
|
||||
.get("node")
|
||||
.and_then(|n| n.get("backendNodeId"))
|
||||
.and_then(|v| v.as_i64());
|
||||
|
||||
let text_result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: get_text_js.to_string(),
|
||||
object_id: Some(object_id.clone()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = text_result
|
||||
.result
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let kind = "clickable";
|
||||
let ref_id = format!("e{}", next_ref);
|
||||
next_ref += 1;
|
||||
|
||||
ref_map.add(ref_id.clone(), backend_node_id, kind, &text, None);
|
||||
|
||||
let escaped = text
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', " ")
|
||||
.replace('\r', " ");
|
||||
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
|
||||
}
|
||||
|
||||
ref_map.set_next_ref_num(next_ref);
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
||||
let mut tree_nodes: Vec<TreeNode> = Vec::with_capacity(nodes.len());
|
||||
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
let role = extract_ax_string(&node.role);
|
||||
let name = extract_ax_string(&node.name);
|
||||
let value_text = extract_ax_string_opt(&node.value);
|
||||
|
||||
let (level, checked, expanded, selected, disabled, required) =
|
||||
extract_properties(&node.properties);
|
||||
|
||||
if node.ignored.unwrap_or(false) && role != "RootWebArea" {
|
||||
tree_nodes.push(TreeNode {
|
||||
role: String::new(),
|
||||
name: String::new(),
|
||||
level: None,
|
||||
checked: None,
|
||||
expanded: None,
|
||||
selected: None,
|
||||
disabled: None,
|
||||
required: None,
|
||||
value_text: None,
|
||||
backend_node_id: None,
|
||||
children: Vec::new(),
|
||||
has_ref: false,
|
||||
ref_id: None,
|
||||
depth: 0,
|
||||
});
|
||||
id_to_idx.insert(node.node_id.clone(), i);
|
||||
continue;
|
||||
}
|
||||
|
||||
tree_nodes.push(TreeNode {
|
||||
role,
|
||||
name,
|
||||
level,
|
||||
checked,
|
||||
expanded,
|
||||
selected,
|
||||
disabled,
|
||||
required,
|
||||
value_text,
|
||||
backend_node_id: node.backend_d_o_m_node_id,
|
||||
children: Vec::new(),
|
||||
has_ref: false,
|
||||
ref_id: None,
|
||||
depth: 0,
|
||||
});
|
||||
id_to_idx.insert(node.node_id.clone(), i);
|
||||
}
|
||||
|
||||
// Build parent-child relationships
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
if let Some(ref child_ids) = node.child_ids {
|
||||
for cid in child_ids {
|
||||
if let Some(&child_idx) = id_to_idx.get(cid) {
|
||||
tree_nodes[i].children.push(child_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set depths
|
||||
let mut root_indices = Vec::new();
|
||||
let children_exist: Vec<bool> = nodes.iter().map(|_| false).collect();
|
||||
let mut is_child = children_exist;
|
||||
for node in &tree_nodes {
|
||||
for &child in &node.children {
|
||||
is_child[child] = true;
|
||||
}
|
||||
}
|
||||
for (i, &is_c) in is_child.iter().enumerate() {
|
||||
if !is_c {
|
||||
root_indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_depth(nodes: &mut [TreeNode], idx: usize, depth: usize) {
|
||||
nodes[idx].depth = depth;
|
||||
let children: Vec<usize> = nodes[idx].children.clone();
|
||||
for child_idx in children {
|
||||
set_depth(nodes, child_idx, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for &root in &root_indices {
|
||||
set_depth(&mut tree_nodes, root, 0);
|
||||
}
|
||||
|
||||
(tree_nodes, root_indices)
|
||||
}
|
||||
|
||||
fn render_tree(
|
||||
nodes: &[TreeNode],
|
||||
idx: usize,
|
||||
indent: usize,
|
||||
output: &mut String,
|
||||
options: &SnapshotOptions,
|
||||
) {
|
||||
let node = &nodes[idx];
|
||||
|
||||
if node.role.is_empty() {
|
||||
// Ignored node -- still render children
|
||||
for &child in &node.children {
|
||||
render_tree(nodes, child, indent, output, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(max_depth) = options.depth {
|
||||
if indent > max_depth {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let role = &node.role;
|
||||
|
||||
// Skip root WebArea wrapper
|
||||
if role == "RootWebArea" || role == "WebArea" {
|
||||
for &child in &node.children {
|
||||
render_tree(nodes, child, indent, output, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if options.interactive && !node.has_ref {
|
||||
// In interactive mode, skip non-interactive but render children
|
||||
for &child in &node.children {
|
||||
render_tree(nodes, child, indent, output, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = " ".repeat(indent);
|
||||
let mut line = format!("{}- {}", prefix, role);
|
||||
|
||||
if !node.name.is_empty() {
|
||||
line.push_str(&format!(" \"{}\"", node.name));
|
||||
}
|
||||
|
||||
// Properties
|
||||
let mut attrs = Vec::new();
|
||||
|
||||
if let Some(level) = node.level {
|
||||
attrs.push(format!("level={}", level));
|
||||
}
|
||||
if let Some(ref checked) = node.checked {
|
||||
attrs.push(format!("checked={}", checked));
|
||||
}
|
||||
if let Some(expanded) = node.expanded {
|
||||
attrs.push(format!("expanded={}", expanded));
|
||||
}
|
||||
if let Some(selected) = node.selected {
|
||||
if selected {
|
||||
attrs.push("selected".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(disabled) = node.disabled {
|
||||
if disabled {
|
||||
attrs.push("disabled".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(required) = node.required {
|
||||
if required {
|
||||
attrs.push("required".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ref_id) = node.ref_id {
|
||||
attrs.push(format!("ref={}", ref_id));
|
||||
}
|
||||
|
||||
if !attrs.is_empty() {
|
||||
line.push_str(&format!(" [{}]", attrs.join(", ")));
|
||||
}
|
||||
|
||||
// Value
|
||||
if let Some(ref val) = node.value_text {
|
||||
if !val.is_empty() && val != &node.name {
|
||||
line.push_str(&format!(": {}", val));
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str(&line);
|
||||
output.push('\n');
|
||||
|
||||
for &child in &node.children {
|
||||
render_tree(nodes, child, indent + 1, output, options);
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_tree(tree: &str, interactive: bool) -> String {
|
||||
let lines: Vec<&str> = tree.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut keep = vec![false; lines.len()];
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if line.contains("[ref=") || line.contains(": ") {
|
||||
keep[i] = true;
|
||||
// Mark ancestors
|
||||
let my_indent = count_indent(line);
|
||||
for j in (0..i).rev() {
|
||||
let ancestor_indent = count_indent(lines[j]);
|
||||
if ancestor_indent < my_indent {
|
||||
keep[j] = true;
|
||||
if ancestor_indent == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result: Vec<&str> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| keep[*i])
|
||||
.map(|(_, line)| *line)
|
||||
.collect();
|
||||
|
||||
let output = result.join("\n");
|
||||
if output.trim().is_empty() && interactive {
|
||||
return "(no interactive elements)".to_string();
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn count_indent(line: &str) -> usize {
|
||||
let trimmed = line.trim_start();
|
||||
(line.len() - trimmed.len()) / 2
|
||||
}
|
||||
|
||||
fn extract_ax_string(value: &Option<AXValue>) -> String {
|
||||
match value {
|
||||
Some(v) => match &v.value {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::Bool(b)) => b.to_string(),
|
||||
_ => String::new(),
|
||||
},
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_ax_string_opt(value: &Option<AXValue>) -> Option<String> {
|
||||
match value {
|
||||
Some(v) => match &v.value {
|
||||
Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
_ => None,
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
type NodeProperties = (
|
||||
Option<i64>, // level
|
||||
Option<String>, // checked
|
||||
Option<bool>, // expanded
|
||||
Option<bool>, // selected
|
||||
Option<bool>, // disabled
|
||||
Option<bool>, // required
|
||||
);
|
||||
|
||||
fn extract_properties(props: &Option<Vec<AXProperty>>) -> NodeProperties {
|
||||
let mut level = None;
|
||||
let mut checked = None;
|
||||
let mut expanded = None;
|
||||
let mut selected = None;
|
||||
let mut disabled = None;
|
||||
let mut required = None;
|
||||
|
||||
if let Some(properties) = props {
|
||||
for prop in properties {
|
||||
match prop.name.as_str() {
|
||||
"level" => {
|
||||
level = prop.value.value.as_ref().and_then(|v| v.as_i64());
|
||||
}
|
||||
"checked" => {
|
||||
checked = prop.value.value.as_ref().map(|v| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
_ => "false".to_string(),
|
||||
});
|
||||
}
|
||||
"expanded" => {
|
||||
expanded = prop.value.value.as_ref().and_then(|v| v.as_bool());
|
||||
}
|
||||
"selected" => {
|
||||
selected = prop.value.value.as_ref().and_then(|v| v.as_bool());
|
||||
}
|
||||
"disabled" => {
|
||||
disabled = prop.value.value.as_ref().and_then(|v| v.as_bool());
|
||||
}
|
||||
"required" => {
|
||||
required = prop.value.value.as_ref().and_then(|v| v.as_bool());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(level, checked, expanded, selected, disabled, required)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_interactive_roles() {
|
||||
assert!(INTERACTIVE_ROLES.contains(&"button"));
|
||||
assert!(INTERACTIVE_ROLES.contains(&"textbox"));
|
||||
assert!(!INTERACTIVE_ROLES.contains(&"heading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_roles() {
|
||||
assert!(CONTENT_ROLES.contains(&"heading"));
|
||||
assert!(!CONTENT_ROLES.contains(&"button"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_tree_basic() {
|
||||
let tree = "- navigation\n - link \"Home\" [ref=e1]\n - link \"About\" [ref=e2]\n- main\n - heading \"Title\"\n - paragraph\n - text: Hello\n";
|
||||
let result = compact_tree(tree, false);
|
||||
assert!(result.contains("[ref=e1]"));
|
||||
assert!(result.contains("[ref=e2]"));
|
||||
assert!(result.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_tree_empty_interactive() {
|
||||
let result = compact_tree("- generic\n", true);
|
||||
assert_eq!(result, "(no interactive elements)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_indent() {
|
||||
assert_eq!(count_indent("- heading"), 0);
|
||||
assert_eq!(count_indent(" - link"), 1);
|
||||
assert_eq!(count_indent(" - text"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_role_name_tracker() {
|
||||
let mut tracker = RoleNameTracker::new();
|
||||
assert_eq!(tracker.track("button", "Submit", 0), 0);
|
||||
assert_eq!(tracker.track("button", "Submit", 1), 1);
|
||||
assert_eq!(tracker.track("button", "Cancel", 2), 0);
|
||||
|
||||
let dups = tracker.get_duplicates();
|
||||
assert!(dups.contains_key("button:Submit"));
|
||||
assert!(!dups.contains_key("button:Cancel"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::EvaluateParams;
|
||||
use super::cookies::{self, Cookie};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageState {
|
||||
pub cookies: Vec<Cookie>,
|
||||
pub origins: Vec<OriginStorage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OriginStorage {
|
||||
pub origin: String,
|
||||
pub local_storage: Vec<StorageEntry>,
|
||||
#[serde(default)]
|
||||
pub session_storage: Vec<StorageEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageEntry {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub async fn save_state(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
path: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
session_id_str: &str,
|
||||
) -> Result<String, String> {
|
||||
let cookies = cookies::get_cookies(client, session_id, None).await?;
|
||||
|
||||
// Get current origin's storage
|
||||
let origin_js = r#"(() => {
|
||||
const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
result.localStorage.push({ name: key, value: localStorage.getItem(key) });
|
||||
}
|
||||
} catch(e) {}
|
||||
try {
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
result.sessionStorage.push({ name: key, value: sessionStorage.getItem(key) });
|
||||
}
|
||||
} catch(e) {}
|
||||
return result;
|
||||
})()"#;
|
||||
|
||||
let origin_result: super::cdp::types::EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: origin_js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let origin_data = origin_result.result.value.unwrap_or(Value::Null);
|
||||
let origins = if origin_data.is_object() {
|
||||
let origin = origin_data
|
||||
.get("origin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let local_storage: Vec<StorageEntry> = origin_data
|
||||
.get("localStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let session_storage: Vec<StorageEntry> = origin_data
|
||||
.get("sessionStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !origin.is_empty() && origin != "null" {
|
||||
vec![OriginStorage {
|
||||
origin,
|
||||
local_storage,
|
||||
session_storage,
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let state = StorageState { cookies, origins };
|
||||
let json_str = serde_json::to_string_pretty(&state)
|
||||
.map_err(|e| format!("Failed to serialize state: {}", e))?;
|
||||
|
||||
let mut save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_sessions_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let name = session_name.unwrap_or("default");
|
||||
dir.join(format!("{}-{}.json", name, session_id_str))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
|
||||
let encrypted = encrypt_data(json_str.as_bytes(), &key)?;
|
||||
save_path.push_str(".enc");
|
||||
fs::write(&save_path, &encrypted)
|
||||
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
|
||||
} else {
|
||||
fs::write(&save_path, &json_str)
|
||||
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
|
||||
}
|
||||
|
||||
Ok(save_path)
|
||||
}
|
||||
|
||||
pub async fn load_state(client: &CdpClient, session_id: &str, path: &str) -> Result<(), String> {
|
||||
let json_str = if path.ends_with(".enc") {
|
||||
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
|
||||
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
|
||||
})?;
|
||||
let data =
|
||||
fs::read(path).map_err(|e| format!("Failed to read state from {}: {}", path, e))?;
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
|
||||
} else {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
|
||||
let enc_path = format!("{}.enc", path);
|
||||
if let Ok(data) = fs::read(&enc_path) {
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|de| format!("Decrypted state is not valid UTF-8: {}", de))?
|
||||
} else {
|
||||
return Err(format!("Failed to read state from {}: {}", path, e));
|
||||
}
|
||||
} else {
|
||||
return Err(format!("Failed to read state from {}: {}", path, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let state: StorageState =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
|
||||
|
||||
// Load cookies
|
||||
if !state.cookies.is_empty() {
|
||||
let cookie_values: Vec<Value> = state
|
||||
.cookies
|
||||
.iter()
|
||||
.map(|c| serde_json::to_value(c).unwrap_or(Value::Null))
|
||||
.collect();
|
||||
cookies::set_cookies(client, session_id, cookie_values, None).await?;
|
||||
}
|
||||
|
||||
// Load storage per origin
|
||||
for origin in &state.origins {
|
||||
if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Navigate to origin to set storage
|
||||
let navigate_url = format!("{}/", origin.origin.trim_end_matches('/'));
|
||||
client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": navigate_url })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Brief wait for navigation
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
for entry in &origin.local_storage {
|
||||
let js = format!(
|
||||
"localStorage.setItem({}, {})",
|
||||
serde_json::to_string(&entry.name).unwrap_or_default(),
|
||||
serde_json::to_string(&entry.value).unwrap_or_default(),
|
||||
);
|
||||
let _ = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
for entry in &origin.session_storage {
|
||||
let js = format!(
|
||||
"sessionStorage.setItem({}, {})",
|
||||
serde_json::to_string(&entry.name).unwrap_or_default(),
|
||||
serde_json::to_string(&entry.value).unwrap_or_default(),
|
||||
);
|
||||
let _ = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_state_file(path: &std::path::Path) -> bool {
|
||||
let fname = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
fname.ends_with(".json") || fname.ends_with(".json.enc")
|
||||
}
|
||||
|
||||
fn is_encrypted_state(path: &std::path::Path) -> bool {
|
||||
path.to_string_lossy().ends_with(".json.enc")
|
||||
}
|
||||
|
||||
pub fn state_list() -> Result<Value, String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "files": [], "directory": dir.to_string_lossy() }));
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read sessions dir: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_state_file(&path) {
|
||||
let metadata = fs::metadata(&path).ok();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
let modified = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let encrypted = is_encrypted_state(&path);
|
||||
|
||||
files.push(json!({
|
||||
"filename": filename,
|
||||
"path": path.to_string_lossy(),
|
||||
"size": size,
|
||||
"modified": modified,
|
||||
"encrypted": encrypted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "files": files, "directory": dir.to_string_lossy() }))
|
||||
}
|
||||
|
||||
pub fn state_show(path: &str) -> Result<Value, String> {
|
||||
let encrypted = path.ends_with(".enc");
|
||||
let json_str = if encrypted {
|
||||
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
|
||||
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
|
||||
})?;
|
||||
let data = fs::read(path).map_err(|e| format!("Failed to read state file: {}", e))?;
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
|
||||
} else {
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read state file: {}", e))?
|
||||
};
|
||||
|
||||
let state: StorageState =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
|
||||
|
||||
let metadata = fs::metadata(path).ok();
|
||||
let filename = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(json!({
|
||||
"filename": filename,
|
||||
"path": path,
|
||||
"size": metadata.as_ref().map(|m| m.len()).unwrap_or(0),
|
||||
"modified": metadata.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
"encrypted": encrypted,
|
||||
"summary": format!("{} cookies, {} origins", state.cookies.len(), state.origins.len()),
|
||||
"state": state,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn state_clear(path: Option<&str>) -> Result<Value, String> {
|
||||
if let Some(p) = path {
|
||||
fs::remove_file(p).map_err(|e| format!("Failed to delete state: {}", e))?;
|
||||
return Ok(json!({ "deleted": p }));
|
||||
}
|
||||
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "deleted": 0 }));
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_state_file(&path) {
|
||||
let _ = fs::remove_file(&path);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "deleted": count }))
|
||||
}
|
||||
|
||||
pub fn state_clean(max_age_days: u64) -> Result<Value, String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "cleaned": 0, "keptCount": 0, "days": max_age_days }));
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now();
|
||||
let max_age = std::time::Duration::from_secs(max_age_days * 86400);
|
||||
let mut deleted = 0;
|
||||
let mut kept = 0;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !is_state_file(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(metadata) = fs::metadata(&path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(age) = now.duration_since(modified) {
|
||||
if age > max_age {
|
||||
let _ = fs::remove_file(&path);
|
||||
deleted += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "cleaned": deleted, "keptCount": kept, "days": max_age_days }))
|
||||
}
|
||||
|
||||
pub fn state_rename(old_path: &str, new_name: &str) -> Result<Value, String> {
|
||||
let old = PathBuf::from(old_path);
|
||||
if !old.exists() {
|
||||
return Err(format!("State file not found: {}", old_path));
|
||||
}
|
||||
|
||||
let fallback = PathBuf::from(".");
|
||||
let dir = old.parent().unwrap_or(&fallback);
|
||||
let new_path = dir.join(format!("{}.json", new_name));
|
||||
|
||||
fs::rename(&old, &new_path).map_err(|e| format!("Failed to rename state: {}", e))?;
|
||||
|
||||
Ok(json!({
|
||||
"renamed": true,
|
||||
"from": old_path,
|
||||
"to": new_path.to_string_lossy(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn encrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_str.as_bytes());
|
||||
let key_bytes = hasher.finalize();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
|
||||
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&nonce), data)
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn decrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
|
||||
if data.len() < 13 {
|
||||
return Err("Ciphertext too short".to_string());
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_str.as_bytes());
|
||||
let key_bytes = hasher.finalize();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{}-", session_name);
|
||||
let mut best_path: Option<(String, std::time::SystemTime)> = None;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let fname = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let is_match = fname.starts_with(&prefix)
|
||||
&& (fname.ends_with(".json") || fname.ends_with(".json.enc"));
|
||||
if !is_match {
|
||||
continue;
|
||||
}
|
||||
let modified = fs::metadata(&path)
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
}
|
||||
best_path.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
pub fn get_sessions_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("sessions")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("sessions")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_storage_state_serialization() {
|
||||
let state = StorageState {
|
||||
cookies: vec![Cookie {
|
||||
name: "session".to_string(),
|
||||
value: "abc123".to_string(),
|
||||
domain: ".example.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 0.0,
|
||||
size: 0,
|
||||
http_only: true,
|
||||
secure: false,
|
||||
session: true,
|
||||
same_site: Some("Lax".to_string()),
|
||||
}],
|
||||
origins: vec![OriginStorage {
|
||||
origin: "https://example.com".to_string(),
|
||||
local_storage: vec![StorageEntry {
|
||||
name: "key".to_string(),
|
||||
value: "val".to_string(),
|
||||
}],
|
||||
session_storage: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
let parsed: StorageState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.cookies.len(), 1);
|
||||
assert_eq!(parsed.cookies[0].name, "session");
|
||||
assert_eq!(parsed.origins.len(), 1);
|
||||
assert_eq!(parsed.origins[0].local_storage.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_state_empty() {
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
origins: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let parsed: StorageState = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.cookies.is_empty());
|
||||
assert!(parsed.origins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_show_nonexistent_file() {
|
||||
let result = state_show("/tmp/nonexistent-agent-browser-state-file.json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_clear_nonexistent_file() {
|
||||
let result = state_clear(Some("/tmp/nonexistent-agent-browser-state-file.json"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_rename_nonexistent() {
|
||||
let result = state_rename("/tmp/nonexistent-agent-browser-state-file.json", "new-name");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_list_returns_json() {
|
||||
let result = state_list().unwrap();
|
||||
assert!(result.get("files").is_some());
|
||||
assert!(result.get("directory").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_dir_path() {
|
||||
let dir = get_sessions_dir();
|
||||
assert!(dir.to_string_lossy().contains("sessions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let plain = b"hello world";
|
||||
let key = "test-secret-key";
|
||||
let encrypted = encrypt_data(plain, key).unwrap();
|
||||
assert!(encrypted.len() > 12);
|
||||
assert_ne!(&encrypted[12..], plain);
|
||||
let decrypted = decrypt_data(&encrypted, key).unwrap();
|
||||
assert_eq!(decrypted, plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_wrong_key_fails() {
|
||||
let plain = b"secret data";
|
||||
let encrypted = encrypt_data(plain, "key1").unwrap();
|
||||
let result = decrypt_data(&encrypted, "key2");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_serde_roundtrip() {
|
||||
let cookie = Cookie {
|
||||
name: "test".to_string(),
|
||||
value: "123".to_string(),
|
||||
domain: ".test.com".to_string(),
|
||||
path: "/api".to_string(),
|
||||
expires: 1700000000.0,
|
||||
size: 7,
|
||||
http_only: false,
|
||||
secure: true,
|
||||
session: false,
|
||||
same_site: Some("Strict".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&cookie).unwrap();
|
||||
assert_eq!(json["name"], "test");
|
||||
assert_eq!(json["httpOnly"], false);
|
||||
assert_eq!(json["secure"], true);
|
||||
assert_eq!(json["sameSite"], "Strict");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::EvaluateParams;
|
||||
|
||||
pub async fn storage_get(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
key: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
|
||||
if let Some(k) = key {
|
||||
let js = format!(
|
||||
"{}.getItem({})",
|
||||
st,
|
||||
serde_json::to_string(k).unwrap_or_default()
|
||||
);
|
||||
let result = eval_simple(client, session_id, &js).await?;
|
||||
Ok(json!({ "key": k, "value": result }))
|
||||
} else {
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const s = {};
|
||||
const data = {{}};
|
||||
for (let i = 0; i < s.length; i++) {{
|
||||
const key = s.key(i);
|
||||
data[key] = s.getItem(key);
|
||||
}}
|
||||
return data;
|
||||
}})()"#,
|
||||
st
|
||||
);
|
||||
let result = eval_simple(client, session_id, &js).await?;
|
||||
Ok(json!({ "data": result }))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn storage_set(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
let js = format!(
|
||||
"{}.setItem({}, {})",
|
||||
st,
|
||||
serde_json::to_string(key).unwrap_or_default(),
|
||||
serde_json::to_string(value).unwrap_or_default(),
|
||||
);
|
||||
eval_simple(client, session_id, &js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn storage_clear(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
) -> Result<(), String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
let js = format!("{}.clear()", st);
|
||||
eval_simple(client, session_id, &js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn storage_js_name(storage_type: &str) -> &str {
|
||||
match storage_type {
|
||||
"session" => "sessionStorage",
|
||||
_ => "localStorage",
|
||||
}
|
||||
}
|
||||
|
||||
async fn eval_simple(client: &CdpClient, session_id: &str, js: &str) -> Result<Value, String> {
|
||||
let result: super::cdp::types::EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ref details) = result.exception_details {
|
||||
return Err(format!("Storage error: {}", details.text));
|
||||
}
|
||||
|
||||
Ok(result.result.value.unwrap_or(Value::Null))
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
/// Frame metadata from CDP Page.screencastFrame events.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameMetadata {
|
||||
pub offset_top: f64,
|
||||
pub page_scale_factor: f64,
|
||||
pub device_width: u32,
|
||||
pub device_height: u32,
|
||||
pub scroll_offset_x: f64,
|
||||
pub scroll_offset_y: f64,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
impl Default for FrameMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
offset_top: 0.0,
|
||||
page_scale_factor: 1.0,
|
||||
device_width: 1280,
|
||||
device_height: 720,
|
||||
scroll_offset_x: 0.0,
|
||||
scroll_offset_y: 0.0,
|
||||
timestamp: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StreamServer {
|
||||
port: u16,
|
||||
frame_tx: broadcast::Sender<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl StreamServer {
|
||||
pub async fn start(
|
||||
preferred_port: u16,
|
||||
client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
) -> Result<Self, String> {
|
||||
let addr = format!("127.0.0.1:{}", preferred_port);
|
||||
let listener = TcpListener::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind stream server: {}", e))?;
|
||||
|
||||
let actual_addr = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get stream address: {}", e))?;
|
||||
let port = actual_addr.port();
|
||||
|
||||
let (frame_tx, _) = broadcast::channel::<String>(64);
|
||||
let client_count = Arc::new(Mutex::new(0usize));
|
||||
|
||||
let frame_tx_clone = frame_tx.clone();
|
||||
let client_count_clone = client_count.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
accept_loop(
|
||||
listener,
|
||||
frame_tx_clone,
|
||||
client_count_clone,
|
||||
client,
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
frame_tx,
|
||||
client_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Broadcast a raw frame string (legacy).
|
||||
pub fn broadcast_frame(&self, frame_json: &str) {
|
||||
let _ = self.frame_tx.send(frame_json.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast a screencast frame with structured metadata.
|
||||
pub fn broadcast_screencast_frame(&self, base64_data: &str, metadata: &FrameMetadata) {
|
||||
let msg = json!({
|
||||
"type": "frame",
|
||||
"data": base64_data,
|
||||
"metadata": {
|
||||
"offsetTop": metadata.offset_top,
|
||||
"pageScaleFactor": metadata.page_scale_factor,
|
||||
"deviceWidth": metadata.device_width,
|
||||
"deviceHeight": metadata.device_height,
|
||||
"scrollOffsetX": metadata.scroll_offset_x,
|
||||
"scrollOffsetY": metadata.scroll_offset_y,
|
||||
"timestamp": metadata.timestamp,
|
||||
}
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast a status message to all connected clients.
|
||||
pub fn broadcast_status(
|
||||
&self,
|
||||
connected: bool,
|
||||
screencasting: bool,
|
||||
viewport_width: u32,
|
||||
viewport_height: u32,
|
||||
) {
|
||||
let msg = json!({
|
||||
"type": "status",
|
||||
"connected": connected,
|
||||
"screencasting": screencasting,
|
||||
"viewportWidth": viewport_width,
|
||||
"viewportHeight": viewport_height,
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast an error message to all connected clients.
|
||||
pub fn broadcast_error(&self, message: &str) {
|
||||
let msg = json!({
|
||||
"type": "error",
|
||||
"message": message,
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
frame_tx: broadcast::Sender<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
cdp_client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
) {
|
||||
while let Ok((stream, addr)) = listener.accept().await {
|
||||
let frame_rx = frame_tx.subscribe();
|
||||
let client_count = client_count.clone();
|
||||
let cdp = cdp_client.clone();
|
||||
let sid = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
handle_ws_client(stream, addr, frame_rx, client_count, cdp, sid).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ws_client(
|
||||
stream: tokio::net::TcpStream,
|
||||
_addr: SocketAddr,
|
||||
mut frame_rx: broadcast::Receiver<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
cdp_client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
) {
|
||||
// Origin checking on WebSocket handshake
|
||||
let callback =
|
||||
|req: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
let origin = req
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
if !is_allowed_origin(origin.as_deref()) {
|
||||
let mut reject =
|
||||
tokio_tungstenite::tungstenite::handshake::server::ErrorResponse::new(Some(
|
||||
"Origin not allowed".to_string(),
|
||||
));
|
||||
*reject.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
|
||||
return Err(reject);
|
||||
}
|
||||
Ok(resp)
|
||||
};
|
||||
|
||||
let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
|
||||
Ok(ws) => ws,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
{
|
||||
let mut count = client_count.lock().await;
|
||||
*count += 1;
|
||||
}
|
||||
|
||||
let (mut ws_tx, mut ws_rx) = ws_stream.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = frame_rx.recv() => {
|
||||
match frame {
|
||||
Ok(data) => {
|
||||
if ws_tx.send(Message::Text(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
msg = ws_rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
handle_client_message(&text, &cdp_client, &session_id).await;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut count = client_count.lock().await;
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client_message(msg: &str, client: &CdpClient, session_id: &str) {
|
||||
let parsed: Value = match serde_json::from_str(msg) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
"input_mouse" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchMouseEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("mouseMoved"),
|
||||
"x": parsed.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"y": parsed.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"button": parsed.get("button").and_then(|v| v.as_str()).unwrap_or("none"),
|
||||
"clickCount": parsed.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
"deltaX": parsed.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"deltaY": parsed.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"input_keyboard" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchKeyEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("keyDown"),
|
||||
"key": parsed.get("key"),
|
||||
"code": parsed.get("code"),
|
||||
"text": parsed.get("text"),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"input_touch" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchTouchEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("touchStart"),
|
||||
"touchPoints": parsed.get("touchPoints").unwrap_or(&json!([])),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"status" => {
|
||||
// Client requesting status -- handled via broadcast_status from the caller
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_allowed_origin(origin: Option<&str>) -> bool {
|
||||
match origin {
|
||||
None => true,
|
||||
Some(o) => {
|
||||
if o.starts_with("file://") {
|
||||
return true;
|
||||
}
|
||||
if let Ok(url) = url::Url::parse(o) {
|
||||
let host = url.host_str().unwrap_or("");
|
||||
host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_screencast(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
format: &str,
|
||||
quality: i32,
|
||||
max_width: i32,
|
||||
max_height: i32,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Page.startScreencast",
|
||||
Some(json!({
|
||||
"format": format,
|
||||
"quality": quality,
|
||||
"maxWidth": max_width,
|
||||
"maxHeight": max_height,
|
||||
"everyNthFrame": 1,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_screencast(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
client
|
||||
.send_command_no_params("Page.stopScreencast", Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ack_screencast_frame(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
screencast_session_id: i64,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Page.screencastFrameAck",
|
||||
Some(json!({ "sessionId": screencast_session_id })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_none() {
|
||||
assert!(is_allowed_origin(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_file() {
|
||||
assert!(is_allowed_origin(Some("file:///path/to/file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_localhost() {
|
||||
assert!(is_allowed_origin(Some("http://localhost:3000")));
|
||||
assert!(is_allowed_origin(Some("http://127.0.0.1:8080")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disallowed_origin() {
|
||||
assert!(!is_allowed_origin(Some("http://evil.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frame_metadata_default() {
|
||||
let meta = FrameMetadata::default();
|
||||
assert_eq!(meta.device_width, 1280);
|
||||
assert_eq!(meta.device_height, 720);
|
||||
assert_eq!(meta.page_scale_factor, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
const MAX_PROFILE_EVENTS: usize = 5_000_000;
|
||||
|
||||
const DEFAULT_PROFILER_CATEGORIES: &[&str] = &[
|
||||
"devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"v8.execute",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
"disabled-by-default-v8.cpu_profiler.hires",
|
||||
"v8",
|
||||
"disabled-by-default-v8.runtime_stats",
|
||||
"blink",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"renderer.scheduler",
|
||||
"sequence_manager",
|
||||
"toplevel",
|
||||
];
|
||||
|
||||
pub struct TracingState {
|
||||
pub active: bool,
|
||||
pub events: Vec<Value>,
|
||||
pub events_dropped: bool,
|
||||
}
|
||||
|
||||
impl TracingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
events: Vec::new(),
|
||||
events_dropped: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn trace_start(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
) -> Result<Value, String> {
|
||||
if tracing_state.active {
|
||||
return Err("Tracing already active".to_string());
|
||||
}
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Tracing.start",
|
||||
Some(json!({
|
||||
"traceConfig": {
|
||||
"recordMode": "recordContinuously",
|
||||
},
|
||||
"transferMode": "ReturnAsStream",
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing_state.active = true;
|
||||
tracing_state.events.clear();
|
||||
tracing_state.events_dropped = false;
|
||||
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
pub async fn trace_stop(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
path: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if !tracing_state.active {
|
||||
return Err("No tracing in progress".to_string());
|
||||
}
|
||||
|
||||
// Subscribe to events before stopping
|
||||
let mut rx = client.subscribe();
|
||||
|
||||
client
|
||||
.send_command_no_params("Tracing.end", Some(session_id))
|
||||
.await?;
|
||||
|
||||
// Collect trace data with timeout
|
||||
let mut trace_events: Vec<Value> = Vec::new();
|
||||
let mut stream_handle: Option<String> = None;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => {
|
||||
if event.session_id.as_deref() != Some(session_id) {
|
||||
continue;
|
||||
}
|
||||
match event.method.as_str() {
|
||||
"Tracing.dataCollected" => {
|
||||
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(arr.iter().cloned());
|
||||
}
|
||||
}
|
||||
"Tracing.tracingComplete" => {
|
||||
stream_handle = event
|
||||
.params
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {
|
||||
return Err("Tracing stop timed out after 30s".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If ReturnAsStream mode was used, read trace data from the IO stream
|
||||
if let Some(handle) = stream_handle {
|
||||
if trace_events.is_empty() {
|
||||
let stream_data = read_io_stream(client, session_id, &handle).await?;
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(&stream_data) {
|
||||
if let Some(events) = parsed.get("traceEvents").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(events.iter().cloned());
|
||||
}
|
||||
} else {
|
||||
// Try parsing as newline-delimited JSON
|
||||
for line in stream_data.lines() {
|
||||
if let Ok(val) = serde_json::from_str::<Value>(line) {
|
||||
if let Some(events) = val.get("traceEvents").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(events.iter().cloned());
|
||||
} else {
|
||||
trace_events.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Close the IO stream
|
||||
let _ = client
|
||||
.send_command(
|
||||
"IO.close",
|
||||
Some(json!({ "handle": handle })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing_state.active = false;
|
||||
|
||||
let save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_traces_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
dir.join(format!("trace-{}.json", timestamp))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let trace_json = json!({ "traceEvents": trace_events });
|
||||
let json_str = serde_json::to_string(&trace_json)
|
||||
.map_err(|e| format!("Failed to serialize trace: {}", e))?;
|
||||
std::fs::write(&save_path, json_str)
|
||||
.map_err(|e| format!("Failed to write trace to {}: {}", save_path, e))?;
|
||||
|
||||
Ok(json!({ "path": save_path, "eventCount": trace_events.len() }))
|
||||
}
|
||||
|
||||
pub async fn profiler_start(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
categories: Option<Vec<String>>,
|
||||
) -> Result<Value, String> {
|
||||
if tracing_state.active {
|
||||
return Err("Profiling/tracing already active".to_string());
|
||||
}
|
||||
|
||||
let cats: Vec<String> = categories.unwrap_or_else(|| {
|
||||
DEFAULT_PROFILER_CATEGORIES
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
});
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Tracing.start",
|
||||
Some(json!({
|
||||
"traceConfig": {
|
||||
"includedCategories": cats,
|
||||
"enableSampling": true,
|
||||
},
|
||||
"transferMode": "ReportEvents",
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing_state.active = true;
|
||||
tracing_state.events.clear();
|
||||
tracing_state.events_dropped = false;
|
||||
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
pub async fn profiler_stop(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
path: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if !tracing_state.active {
|
||||
return Err("No profiling in progress".to_string());
|
||||
}
|
||||
|
||||
let mut rx = client.subscribe();
|
||||
|
||||
client
|
||||
.send_command_no_params("Tracing.end", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let mut events: Vec<Value> = Vec::new();
|
||||
let mut dropped = false;
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => {
|
||||
if event.session_id.as_deref() != Some(session_id) {
|
||||
continue;
|
||||
}
|
||||
match event.method.as_str() {
|
||||
"Tracing.dataCollected" => {
|
||||
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
|
||||
if events.len() + arr.len() > MAX_PROFILE_EVENTS {
|
||||
dropped = true;
|
||||
} else {
|
||||
events.extend(arr.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
"Tracing.tracingComplete" => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {
|
||||
return Err("Profiler stop timed out after 30s".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing_state.active = false;
|
||||
|
||||
let save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_profiles_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
dir.join(format!("profile-{}.json", timestamp))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let clock_domain = get_clock_domain();
|
||||
let mut profile = json!({ "traceEvents": events });
|
||||
if let Some(cd) = clock_domain {
|
||||
profile
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("metadata".to_string(), json!({ "clock-domain": cd }));
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
std::fs::write(&save_path, json_str)
|
||||
.map_err(|e| format!("Failed to write profile to {}: {}", save_path, e))?;
|
||||
|
||||
let event_count = events.len();
|
||||
let mut result = json!({ "path": save_path, "eventCount": event_count });
|
||||
if dropped {
|
||||
result.as_object_mut().unwrap().insert(
|
||||
"warning".to_string(),
|
||||
Value::String(format!(
|
||||
"Events exceeded {} limit; some dropped",
|
||||
MAX_PROFILE_EVENTS
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Read all data from a CDP IO stream handle.
|
||||
async fn read_io_stream(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
handle: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut data = String::new();
|
||||
loop {
|
||||
let result = client
|
||||
.send_command(
|
||||
"IO.read",
|
||||
Some(json!({
|
||||
"handle": handle,
|
||||
"size": 1024 * 1024,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(chunk) = result.get("data").and_then(|v| v.as_str()) {
|
||||
data.push_str(chunk);
|
||||
}
|
||||
|
||||
let eof = result.get("eof").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn get_clock_domain() -> Option<&'static str> {
|
||||
if cfg!(target_os = "linux") {
|
||||
Some("LINUX_CLOCK_MONOTONIC")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Some("MAC_MACH_ABSOLUTE_TIME")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_traces_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("traces")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("traces")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profiles_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("profiles")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("profiles")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::client::WebDriverClient;
|
||||
|
||||
const APPIUM_DEFAULT_PORT: u16 = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
pub struct AppiumManager {
|
||||
pub client: WebDriverClient,
|
||||
appium_process: Option<Child>,
|
||||
pub device_udid: Option<String>,
|
||||
}
|
||||
|
||||
impl AppiumManager {
|
||||
pub async fn connect_or_launch(device_udid: Option<&str>) -> Result<Self, String> {
|
||||
let port = APPIUM_DEFAULT_PORT;
|
||||
let client = WebDriverClient::new(port);
|
||||
|
||||
// Check if Appium is already running
|
||||
if is_appium_running(port).await {
|
||||
return Ok(Self {
|
||||
client,
|
||||
appium_process: None,
|
||||
device_udid: device_udid.map(String::from),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to launch Appium
|
||||
let appium_process = launch_appium(port)?;
|
||||
|
||||
// Wait for Appium to be ready
|
||||
wait_for_appium(port, APPIUM_STARTUP_TIMEOUT_SECS).await?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
appium_process: Some(appium_process),
|
||||
device_udid: device_udid.map(String::from),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_ios_session(
|
||||
&mut self,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let mut caps = json!({
|
||||
"platformName": "iOS",
|
||||
"automationName": "XCUITest",
|
||||
"browserName": "Safari",
|
||||
"noReset": true,
|
||||
});
|
||||
|
||||
if let Some(name) = device_name {
|
||||
caps["deviceName"] = json!(name);
|
||||
} else {
|
||||
caps["deviceName"] = json!("iPhone");
|
||||
}
|
||||
|
||||
if let Some(ver) = platform_version {
|
||||
caps["platformVersion"] = json!(ver);
|
||||
}
|
||||
|
||||
if let Some(ref udid) = self.device_udid {
|
||||
caps["udid"] = json!(udid);
|
||||
}
|
||||
|
||||
self.client.create_session(caps).await
|
||||
}
|
||||
|
||||
pub async fn tap(&self, x: f64, y: f64) -> Result<(), String> {
|
||||
let sid = self
|
||||
.client
|
||||
.session_id_pub()
|
||||
.ok_or("No active session")?
|
||||
.to_string();
|
||||
let actions = json!({
|
||||
"actions": [{
|
||||
"type": "pointer",
|
||||
"id": "finger1",
|
||||
"parameters": { "pointerType": "touch" },
|
||||
"actions": [
|
||||
{ "type": "pointerMove", "duration": 0, "x": x as i64, "y": y as i64 },
|
||||
{ "type": "pointerDown", "button": 0 },
|
||||
{ "type": "pause", "duration": 100 },
|
||||
{ "type": "pointerUp", "button": 0 },
|
||||
]
|
||||
}]
|
||||
});
|
||||
self.client.execute_actions(&sid, &actions).await
|
||||
}
|
||||
|
||||
pub async fn swipe(
|
||||
&self,
|
||||
start_x: f64,
|
||||
start_y: f64,
|
||||
end_x: f64,
|
||||
end_y: f64,
|
||||
duration_ms: u64,
|
||||
) -> Result<(), String> {
|
||||
let sid = self
|
||||
.client
|
||||
.session_id_pub()
|
||||
.ok_or("No active session")?
|
||||
.to_string();
|
||||
let actions = json!({
|
||||
"actions": [{
|
||||
"type": "pointer",
|
||||
"id": "finger1",
|
||||
"parameters": { "pointerType": "touch" },
|
||||
"actions": [
|
||||
{ "type": "pointerMove", "duration": 0, "x": start_x as i64, "y": start_y as i64 },
|
||||
{ "type": "pointerDown", "button": 0 },
|
||||
{ "type": "pointerMove", "duration": duration_ms, "x": end_x as i64, "y": end_y as i64 },
|
||||
{ "type": "pointerUp", "button": 0 },
|
||||
]
|
||||
}]
|
||||
});
|
||||
self.client.execute_actions(&sid, &actions).await
|
||||
}
|
||||
|
||||
pub async fn close(&mut self) -> Result<(), String> {
|
||||
let _ = self.client.delete_session().await;
|
||||
if let Some(ref mut child) = self.appium_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AppiumManager {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref mut child) = self.appium_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_appium_running(port: u16) -> bool {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn launch_appium(port: u16) -> Result<Child, String> {
|
||||
// Try npx appium first, then direct appium
|
||||
let result = Command::new("npx")
|
||||
.args(["appium", "--relaxed-security", "--port", &port.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
match result {
|
||||
Ok(child) => Ok(child),
|
||||
Err(_) => Command::new("appium")
|
||||
.args(["--relaxed-security", "--port", &port.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to launch Appium. Install it with: npm install -g appium. Error: {}",
|
||||
e
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_appium(port: u16, timeout_secs: u64) -> Result<(), String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
return Err("Timeout waiting for Appium to start".to_string());
|
||||
}
|
||||
if is_appium_running(port).await {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_appium_constants() {
|
||||
assert_eq!(APPIUM_DEFAULT_PORT, 4723);
|
||||
assert_eq!(APPIUM_STARTUP_TIMEOUT_SECS, 30);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Abstract backend for browser automation. CDP (Chromium) and WebDriver
|
||||
/// (Safari/iOS) share this interface so actions.rs can remain backend-agnostic
|
||||
/// in the future.
|
||||
#[async_trait]
|
||||
pub trait BrowserBackend: Send + Sync {
|
||||
async fn navigate(&self, url: &str) -> Result<(), String>;
|
||||
async fn get_url(&self) -> Result<String, String>;
|
||||
async fn get_title(&self) -> Result<String, String>;
|
||||
async fn get_content(&self) -> Result<String, String>;
|
||||
async fn evaluate(&self, script: &str) -> Result<Value, String>;
|
||||
async fn screenshot(&self) -> Result<String, String>;
|
||||
async fn click(&self, selector: &str) -> Result<(), String>;
|
||||
async fn fill(&self, selector: &str, value: &str) -> Result<(), String>;
|
||||
async fn close(&mut self) -> Result<(), String>;
|
||||
async fn back(&self) -> Result<(), String>;
|
||||
async fn forward(&self) -> Result<(), String>;
|
||||
async fn reload(&self) -> Result<(), String>;
|
||||
async fn get_cookies(&self) -> Result<Value, String>;
|
||||
fn backend_type(&self) -> &str;
|
||||
|
||||
fn supports(&self, feature: &str) -> bool {
|
||||
match feature {
|
||||
"navigate" | "evaluate" | "screenshot" | "click" | "fill" => true,
|
||||
"screencast" | "tracing" | "network_intercept" | "cdp" => self.backend_type() == "cdp",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_error(&self, action: &str) -> String {
|
||||
format!(
|
||||
"Action '{}' is not supported on the {} backend",
|
||||
action,
|
||||
self.backend_type()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDriver implementation of BrowserBackend
|
||||
pub struct WebDriverBackend {
|
||||
client: super::client::WebDriverClient,
|
||||
}
|
||||
|
||||
impl WebDriverBackend {
|
||||
pub fn new(client: super::client::WebDriverClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BrowserBackend for WebDriverBackend {
|
||||
async fn navigate(&self, url: &str) -> Result<(), String> {
|
||||
self.client.navigate(url).await
|
||||
}
|
||||
|
||||
async fn get_url(&self) -> Result<String, String> {
|
||||
self.client.get_url().await
|
||||
}
|
||||
|
||||
async fn get_title(&self) -> Result<String, String> {
|
||||
self.client.get_title().await
|
||||
}
|
||||
|
||||
async fn get_content(&self) -> Result<String, String> {
|
||||
self.client.get_page_source().await
|
||||
}
|
||||
|
||||
async fn evaluate(&self, script: &str) -> Result<Value, String> {
|
||||
self.client.execute_script(script, vec![]).await
|
||||
}
|
||||
|
||||
async fn screenshot(&self) -> Result<String, String> {
|
||||
self.client.screenshot().await
|
||||
}
|
||||
|
||||
async fn click(&self, selector: &str) -> Result<(), String> {
|
||||
let element_id = self.client.find_element("css selector", selector).await?;
|
||||
self.client.click_element(&element_id).await
|
||||
}
|
||||
|
||||
async fn fill(&self, selector: &str, value: &str) -> Result<(), String> {
|
||||
let element_id = self.client.find_element("css selector", selector).await?;
|
||||
self.client.clear_element(&element_id).await?;
|
||||
self.client.send_keys(&element_id, value).await
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), String> {
|
||||
self.client.delete_session().await
|
||||
}
|
||||
|
||||
async fn back(&self) -> Result<(), String> {
|
||||
self.client.back().await
|
||||
}
|
||||
|
||||
async fn forward(&self) -> Result<(), String> {
|
||||
self.client.forward().await
|
||||
}
|
||||
|
||||
async fn reload(&self) -> Result<(), String> {
|
||||
self.client.refresh().await
|
||||
}
|
||||
|
||||
async fn get_cookies(&self) -> Result<Value, String> {
|
||||
self.client.get_cookies().await
|
||||
}
|
||||
|
||||
fn backend_type(&self) -> &str {
|
||||
"webdriver"
|
||||
}
|
||||
}
|
||||
|
||||
/// CDP-backed backend constants for unsupported actions on WebDriver
|
||||
pub const WEBDRIVER_UNSUPPORTED_ACTIONS: &[&str] = &[
|
||||
"screencast_start",
|
||||
"screencast_stop",
|
||||
"trace_start",
|
||||
"trace_stop",
|
||||
"profiler_start",
|
||||
"profiler_stop",
|
||||
"route",
|
||||
"unroute",
|
||||
"expose",
|
||||
"addscript",
|
||||
"addinitscript",
|
||||
"network",
|
||||
"har_start",
|
||||
"har_stop",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_actions() {
|
||||
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"screencast_start"));
|
||||
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"trace_start"));
|
||||
assert!(!WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"navigate"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct WebDriverClient {
|
||||
base_url: String,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WebDriverClient {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session(&mut self, capabilities: Value) -> Result<Value, String> {
|
||||
let body = json!({
|
||||
"capabilities": {
|
||||
"alwaysMatch": capabilities,
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.post("/session", &body).await?;
|
||||
|
||||
let session_id = response
|
||||
.get("value")
|
||||
.and_then(|v| v.get("sessionId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No sessionId in response")?
|
||||
.to_string();
|
||||
|
||||
self.session_id = Some(session_id);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn delete_session(&mut self) -> Result<(), String> {
|
||||
if let Some(ref sid) = self.session_id.clone() {
|
||||
let _ = self.delete(&format!("/session/{}", sid)).await;
|
||||
self.session_id = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn navigate(&self, url: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/url", sid), &json!({ "url": url }))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_url(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/url", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_title(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/title", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn find_element(&self, using: &str, value: &str) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self
|
||||
.post(
|
||||
&format!("/session/{}/element", sid),
|
||||
&json!({ "using": using, "value": value }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let element_value = response.get("value").ok_or("No element in response")?;
|
||||
|
||||
element_value
|
||||
.get("element-6066-11e4-a52e-4f735466cecf")
|
||||
.or_else(|| element_value.get("ELEMENT"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or("No element ID in response".to_string())
|
||||
}
|
||||
|
||||
pub async fn click_element(&self, element_id: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/click", sid, element_id),
|
||||
&json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_keys(&self, element_id: &str, text: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/value", sid, element_id),
|
||||
&json!({ "text": text }),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_element(&self, element_id: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/clear", sid, element_id),
|
||||
&json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_script(&self, script: &str, args: Vec<Value>) -> Result<Value, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self
|
||||
.post(
|
||||
&format!("/session/{}/execute/sync", sid),
|
||||
&json!({ "script": script, "args": args }),
|
||||
)
|
||||
.await?;
|
||||
Ok(response.get("value").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn screenshot(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/screenshot", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_cookies(&self) -> Result<Value, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/cookie", sid)).await?;
|
||||
Ok(response.get("value").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn get_page_source(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/source", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn back(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/back", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn forward(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/forward", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/refresh", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_id_pub(&self) -> Option<&str> {
|
||||
self.session_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn new_with_session(port: u16, session_id: String) -> Self {
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
session_id: Some(session_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_actions(&self, session_id: &str, actions: &Value) -> Result<(), String> {
|
||||
self.post(&format!("/session/{}/actions", session_id), actions)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn session_id(&self) -> Result<&str, String> {
|
||||
self.session_id
|
||||
.as_deref()
|
||||
.ok_or("No active WebDriver session".to_string())
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<Value, String> {
|
||||
http_request("GET", &format!("{}{}", self.base_url, path), None).await
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: &Value) -> Result<Value, String> {
|
||||
http_request("POST", &format!("{}{}", self.base_url, path), Some(body)).await
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<Value, String> {
|
||||
http_request("DELETE", &format!("{}{}", self.base_url, path), None).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_new() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:4444");
|
||||
assert!(client.session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_none() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
let result = client.session_id();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No active WebDriver session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_custom_port() {
|
||||
let client = WebDriverClient::new(9515);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:9515");
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<Value, String> {
|
||||
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port().unwrap_or(80);
|
||||
let path = parsed.path();
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let stream = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("Connection timeout: {}", addr))?
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let body_str = body
|
||||
.map(|b| serde_json::to_string(b).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let request = if body.is_some() {
|
||||
format!(
|
||||
"{} {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
method, path, addr, body_str.len(), body_str
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
||||
method, path, addr
|
||||
)
|
||||
};
|
||||
|
||||
let mut stream = stream;
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Write failed: {}", e))?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| format!("Read failed: {}", e))?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
let body_part = response_str.split("\r\n\r\n").nth(1).unwrap_or("").trim();
|
||||
|
||||
// Handle chunked encoding
|
||||
let json_body = if body_part.contains('\n')
|
||||
&& body_part
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_hexdigit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Chunked: skip chunk size lines
|
||||
body_part
|
||||
.lines()
|
||||
.filter(|l| !l.chars().all(|c| c.is_ascii_hexdigit() || c == '\r'))
|
||||
.collect::<Vec<&str>>()
|
||||
.join("")
|
||||
} else {
|
||||
body_part.to_string()
|
||||
};
|
||||
|
||||
if json_body.is_empty() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
|
||||
serde_json::from_str(&json_body).map_err(|e| {
|
||||
format!(
|
||||
"Invalid JSON response: {} (body: {})",
|
||||
e,
|
||||
json_body.chars().take(100).collect::<String>()
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IosDevice {
|
||||
pub name: String,
|
||||
pub udid: String,
|
||||
pub state: String,
|
||||
pub runtime: String,
|
||||
pub is_real: bool,
|
||||
}
|
||||
|
||||
pub fn list_simulators() -> Result<Vec<IosDevice>, String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "list", "devices", "--json"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xcrun simctl: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("xcrun simctl failed. Xcode may not be installed.".to_string());
|
||||
}
|
||||
|
||||
let json_str = String::from_utf8_lossy(&output.stdout);
|
||||
let parsed: Value =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse simctl: {}", e))?;
|
||||
|
||||
let mut devices = Vec::new();
|
||||
if let Some(device_map) = parsed.get("devices").and_then(|v| v.as_object()) {
|
||||
for (runtime, device_list) in device_map {
|
||||
if let Some(arr) = device_list.as_array() {
|
||||
for device in arr {
|
||||
let name = device
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let udid = device
|
||||
.get("udid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let state = device
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
devices.push(IosDevice {
|
||||
name,
|
||||
udid,
|
||||
state,
|
||||
runtime: runtime.clone(),
|
||||
is_real: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn list_real_devices() -> Result<Vec<IosDevice>, String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["xctrace", "list", "devices"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xcrun xctrace: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut devices = Vec::new();
|
||||
let mut in_devices = false;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("== Devices ==") {
|
||||
in_devices = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("== Simulators ==") {
|
||||
break;
|
||||
}
|
||||
if !in_devices || trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Format: "Device Name (OS Version) (UDID)"
|
||||
if let Some(udid_start) = trimmed.rfind('(') {
|
||||
let udid_end = trimmed.len() - 1;
|
||||
let udid = &trimmed[udid_start + 1..udid_end];
|
||||
// Validate it looks like a UDID (contains hyphens)
|
||||
if udid.contains('-') && udid.len() > 20 {
|
||||
let name_part = trimmed[..udid_start].trim();
|
||||
let name = if let Some(paren_pos) = name_part.rfind('(') {
|
||||
name_part[..paren_pos].trim().to_string()
|
||||
} else {
|
||||
name_part.to_string()
|
||||
};
|
||||
devices.push(IosDevice {
|
||||
name,
|
||||
udid: udid.to_string(),
|
||||
state: "Connected".to_string(),
|
||||
runtime: String::new(),
|
||||
is_real: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn list_all_devices() -> Result<Vec<IosDevice>, String> {
|
||||
let mut all = list_simulators().unwrap_or_default();
|
||||
all.extend(list_real_devices().unwrap_or_default());
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn boot_simulator(udid: &str) -> Result<(), String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "boot", udid])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to boot simulator: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("current state: Booted") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to boot simulator {}: {}", udid, stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown_simulator(udid: &str) -> Result<(), String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "shutdown", udid])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to shutdown simulator: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("current state: Shutdown") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to shutdown simulator {}: {}", udid, stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn select_device(device_name: Option<&str>, udid: Option<&str>) -> Result<IosDevice, String> {
|
||||
if let Some(u) = udid {
|
||||
let devices = list_all_devices()?;
|
||||
return devices
|
||||
.into_iter()
|
||||
.find(|d| d.udid == u)
|
||||
.ok_or_else(|| format!("Device with UDID '{}' not found", u));
|
||||
}
|
||||
|
||||
if let Some(name) = device_name {
|
||||
let devices = list_all_devices()?;
|
||||
return devices
|
||||
.into_iter()
|
||||
.find(|d| d.name.to_lowercase().contains(&name.to_lowercase()))
|
||||
.ok_or_else(|| format!("Device '{}' not found", name));
|
||||
}
|
||||
|
||||
// Default: prefer most recent iPhone, prefer Pro
|
||||
let devices = list_simulators()?;
|
||||
let iphone_devices: Vec<&IosDevice> = devices
|
||||
.iter()
|
||||
.filter(|d| d.name.starts_with("iPhone"))
|
||||
.collect();
|
||||
|
||||
if iphone_devices.is_empty() {
|
||||
return devices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or("No iOS simulators found".to_string());
|
||||
}
|
||||
|
||||
// Prefer Pro models
|
||||
if let Some(pro) = iphone_devices.iter().find(|d| d.name.contains("Pro")) {
|
||||
return Ok((*pro).clone());
|
||||
}
|
||||
|
||||
Ok((*iphone_devices.last().unwrap()).clone())
|
||||
}
|
||||
|
||||
pub fn to_device_json(devices: &[IosDevice]) -> Value {
|
||||
let list: Vec<Value> = devices
|
||||
.iter()
|
||||
.map(|d| {
|
||||
json!({
|
||||
"name": d.name,
|
||||
"udid": d.udid,
|
||||
"state": d.state,
|
||||
"runtime": d.runtime,
|
||||
"isReal": d.is_real,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "devices": list })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ios_device_struct() {
|
||||
let device = IosDevice {
|
||||
name: "iPhone 15 Pro".to_string(),
|
||||
udid: "ABC-123".to_string(),
|
||||
state: "Booted".to_string(),
|
||||
runtime: "iOS-17-0".to_string(),
|
||||
is_real: false,
|
||||
};
|
||||
assert_eq!(device.name, "iPhone 15 Pro");
|
||||
assert!(!device.is_real);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_device_json() {
|
||||
let devices = vec![IosDevice {
|
||||
name: "Test".to_string(),
|
||||
udid: "123".to_string(),
|
||||
state: "Shutdown".to_string(),
|
||||
runtime: "iOS-17".to_string(),
|
||||
is_real: false,
|
||||
}];
|
||||
let json = to_device_json(&devices);
|
||||
assert!(json.get("devices").unwrap().as_array().unwrap().len() == 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod appium;
|
||||
pub mod backend;
|
||||
pub mod client;
|
||||
pub mod ios;
|
||||
pub mod safari;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct SafariDriverProcess {
|
||||
child: Child,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl SafariDriverProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SafariDriverProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_safaridriver() -> Option<PathBuf> {
|
||||
let candidates = ["/usr/bin/safaridriver"];
|
||||
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Try PATH
|
||||
if let Ok(output) = Command::new("which").arg("safaridriver").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn launch_safaridriver(port: u16) -> Result<SafariDriverProcess, String> {
|
||||
let driver_path = find_safaridriver()
|
||||
.ok_or("safaridriver not found. Safari WebDriver requires macOS with Safari.")?;
|
||||
|
||||
let child = Command::new(&driver_path)
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch safaridriver: {}", e))?;
|
||||
|
||||
// Wait for driver to be ready
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
Ok(SafariDriverProcess { child, port })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_safaridriver() {
|
||||
// Only check on macOS
|
||||
if cfg!(target_os = "macos") {
|
||||
let result = find_safaridriver();
|
||||
// Don't assert Some since it may not be enabled
|
||||
if let Some(path) = result {
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewSessionRequest {
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Capabilities {
|
||||
pub always_match: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionResponse {
|
||||
pub value: SessionValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionValue {
|
||||
pub session_id: String,
|
||||
pub capabilities: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebDriverResponse {
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebDriverError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ElementResponse {
|
||||
pub value: ElementValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ElementValue {
|
||||
#[serde(rename = "element-6066-11e4-a52e-4f735466cecf")]
|
||||
pub element_id: Option<String>,
|
||||
#[serde(rename = "ELEMENT")]
|
||||
pub element_legacy: Option<String>,
|
||||
}
|
||||
|
||||
impl ElementValue {
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
self.element_id
|
||||
.as_deref()
|
||||
.or(self.element_legacy.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FindElementRequest {
|
||||
pub using: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExecuteScriptRequest {
|
||||
pub script: String,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieRequest {
|
||||
pub cookie: CookieData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieData {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secure: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiry: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
+232
-30
@@ -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;
|
||||
}
|
||||
@@ -793,11 +874,13 @@ Global Options:
|
||||
--session <name> Use specific session
|
||||
--headers <json> Set HTTP headers (scoped to this origin)
|
||||
--risk-mode <mode> Risk handling for verify/captcha pages: off, warn, block
|
||||
--wait-until <mode> Navigation wait strategy: load, domcontentloaded, networkidle
|
||||
--headed Show browser window
|
||||
|
||||
Examples:
|
||||
agent-browser open example.com
|
||||
agent-browser --risk-mode block open example.com
|
||||
agent-browser --wait-until domcontentloaded open example.com
|
||||
agent-browser open https://github.com
|
||||
agent-browser open localhost:3000
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
|
||||
@@ -2040,7 +2123,8 @@ Operations:
|
||||
clean --older-than <days> Delete expired state files
|
||||
|
||||
Automatic State Persistence:
|
||||
Use --session-name to auto-save/restore state across restarts:
|
||||
Use --session-name to auto-save/restore state across restarts.
|
||||
If omitted, it defaults to --session (or "default"):
|
||||
agent-browser --session-name myapp open https://example.com
|
||||
Or set AGENT_BROWSER_SESSION_NAME environment variable.
|
||||
|
||||
@@ -2148,6 +2232,33 @@ Examples:
|
||||
agent-browser click @e1
|
||||
"##
|
||||
}
|
||||
"doctor" => {
|
||||
r##"
|
||||
agent-browser doctor - Diagnose CDP, sourceURL sanitization, 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
|
||||
- CDP sourceURL sanitization probe (Runtime.evaluate leakage check)
|
||||
- Plugin handshake page context suitability (internal page vs http(s))
|
||||
- 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 CDP mode, a normal http(s) page, and the extension installed
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser doctor
|
||||
agent-browser --json doctor
|
||||
"##
|
||||
}
|
||||
|
||||
// === iOS Commands ===
|
||||
"tap" => {
|
||||
@@ -2282,6 +2393,7 @@ pub fn print_help() {
|
||||
agent-browser - fast browser automation CLI for AI agents
|
||||
|
||||
Usage: agent-browser <command> [args] [options]
|
||||
Aliases: agent-browser, agent-browser-stealth, abs
|
||||
|
||||
Core Commands:
|
||||
open <url> Navigate to URL
|
||||
@@ -2376,6 +2488,7 @@ Sessions:
|
||||
Setup:
|
||||
install Install browser binaries
|
||||
install --with-deps Also install system dependencies (Linux)
|
||||
doctor Diagnose CDP + sourceURL + plugin health
|
||||
|
||||
Snapshot Options:
|
||||
-i, --interactive Only interactive elements
|
||||
@@ -2409,14 +2522,18 @@ Options:
|
||||
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)
|
||||
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
||||
--tab-group <name> Base title for agent tab groups (CDP plugin mode; silent no-op if plugin unavailable)
|
||||
--tab-group-plugin-id <id> Expected Chrome extension ID for tab-group handshake (or AGENT_BROWSER_TAB_GROUP_PLUGIN_ID)
|
||||
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
|
||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||
--wait-until <mode> Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle
|
||||
--session-name <name> Auto-save/restore session state (defaults to --session)
|
||||
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
|
||||
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
|
||||
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
|
||||
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
|
||||
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
|
||||
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
|
||||
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
|
||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||
--debug Debug output
|
||||
--version, -V Show version (fork builds include upstream/fork info)
|
||||
@@ -2448,7 +2565,7 @@ Configuration:
|
||||
Environment:
|
||||
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
||||
AGENT_BROWSER_SESSION Session name (default: "default")
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name (default: AGENT_BROWSER_SESSION)
|
||||
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
|
||||
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
|
||||
@@ -2467,6 +2584,8 @@ Environment:
|
||||
AGENT_BROWSER_TIMEZONE Override auto-detected timezone (e.g., Asia/Taipei)
|
||||
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
|
||||
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
|
||||
AGENT_BROWSER_TAB_GROUP Base title for tab groups (default: "Agent Browser Stealth"; session suffix auto-appended)
|
||||
AGENT_BROWSER_TAB_GROUP_PLUGIN_ID Expected Chrome extension ID for tab-group handshake (default: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block)
|
||||
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
||||
@@ -2481,6 +2600,7 @@ Environment:
|
||||
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
|
||||
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
|
||||
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
|
||||
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
|
||||
|
||||
Install (recommended, fastest - native Rust CLI):
|
||||
npm install -g agent-browser-stealth
|
||||
@@ -2531,6 +2651,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")
|
||||
@@ -2622,7 +2821,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))
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# PRD: CLI Web 数据采集体验优化(以小红书场景为例)
|
||||
|
||||
- 文档版本: v0.1
|
||||
- 状态: Draft
|
||||
- 作者: Codex
|
||||
- 日期: 2026-03-04
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
在使用 `agent-browser` CLI 执行「小红书宠物博主采集(100 条)」时,当前流程可完成任务,但存在明显的可用性与稳定性痛点:
|
||||
|
||||
1. 网络层可观测性不足,响应体抓取不稳定,需注入脚本劫持。
|
||||
2. 分页采集依赖手工 `scroll down + wait`,重复劳动且易漏数据。
|
||||
3. 结构化导出缺少一站式命令,需要 `eval` 二次解析。
|
||||
4. 页面交互依赖文本选择,页面文案变动后脆弱。
|
||||
5. 反爬失败时缺少可解释的自动回退策略。
|
||||
6. 用户对“可抓字段”预期不清(例如搜索接口无联系方式)。
|
||||
7. 长会话缺少快照与断点续抓机制。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
## 2.1 目标
|
||||
|
||||
1. 将常见采集链路从“脚本拼接”降为“CLI 原生命令组合”。
|
||||
2. 让关键动作具备可观测性(日志)和可恢复性(快照/续跑)。
|
||||
3. 降低站点轻微改版、反爬限制带来的失败率。
|
||||
|
||||
## 2.2 非目标
|
||||
|
||||
1. 不承诺绕过平台强风控或登录体系。
|
||||
2. 不在本期实现完整通用爬虫 DSL。
|
||||
3. 不默认抓取平台未公开展示的隐私字段。
|
||||
|
||||
## 3. 目标用户与核心场景
|
||||
|
||||
1. 增长/运营: 按关键词采集账号基础数据并导出 CSV。
|
||||
2. 测试/研发: 复现抓取问题,定位请求失败原因。
|
||||
3. AI Agent 工作流: 在 CLI 内稳定执行“搜索 -> 翻页 -> 提取 -> 导出”。
|
||||
|
||||
## 4. 需求范围与优先级
|
||||
|
||||
## 4.1 P0
|
||||
|
||||
1. `network capture` 增强模式(可过滤、可落盘 response body)。
|
||||
2. `scroll-collect` 自动滚动采集(按页数或直到无新增)。
|
||||
3. `extract` / `extract-to` 结构化导出(JSON/CSV)。
|
||||
|
||||
## 4.2 P1
|
||||
|
||||
1. 语义选择器与 fallback 链(role/aria/data/text)。
|
||||
2. 401/403/406 智能回退(页面触发 + 回包监听)。
|
||||
3. 可抓字段矩阵与二段式采集文档提示。
|
||||
|
||||
## 4.3 P2
|
||||
|
||||
1. `session snapshot` + `crawl resume` 断点续抓。
|
||||
|
||||
## 5. CLI 方案设计
|
||||
|
||||
## 5.1 网络捕获增强
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser network capture --match '/api/sns/web/v1/search/usersearch' --save ./out.ndjson
|
||||
agent-browser network capture --domain edith.xiaohongshu.com --method POST --save ./xhs_usersearch.ndjson
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
- `--match <regex>`: 按 URL 正则过滤。
|
||||
- `--domain <host>`: 按域名过滤。
|
||||
- `--method <GET|POST|...>`: 按方法过滤。
|
||||
- `--status <code|range>`: 按状态过滤。
|
||||
- `--save <path>`: NDJSON 输出文件。
|
||||
- `--include-body <request|response|both>`: 控制 body 输出范围。
|
||||
- `--max-body-bytes <n>`: 单条 body 截断阈值。
|
||||
|
||||
NDJSON 记录结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-03-04T10:00:00.123Z",
|
||||
"session_id": "sess_abc",
|
||||
"request_id": "req_123",
|
||||
"method": "POST",
|
||||
"url": "https://edith.xiaohongshu.com/api/sns/web/v1/search/usersearch",
|
||||
"status": 200,
|
||||
"duration_ms": 312,
|
||||
"request_headers": {"content-type": "application/json"},
|
||||
"request_body": "{...}",
|
||||
"response_headers": {"content-type": "application/json"},
|
||||
"response_body": "{...}",
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
## 5.2 自动滚动采集
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser scroll-collect --until no-new-items --max-steps 200 --idle-rounds 3
|
||||
agent-browser scroll-collect --pages 20 --wait-ms 1200
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
1. 每轮执行滚动与等待。
|
||||
2. 基于 DOM 项数量或网络新增请求判断“是否有新增”。
|
||||
3. 达到停止条件后输出结束原因。
|
||||
|
||||
输出示例:
|
||||
|
||||
```text
|
||||
step=1 new_items=15 total_items=15
|
||||
step=2 new_items=15 total_items=30
|
||||
...
|
||||
stop_reason=no-new-items idle_rounds=3 total_items=135
|
||||
```
|
||||
|
||||
## 5.3 结构化提取与导出
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser extract --from network --match usersearch --fields 'name,fans,note_count,red_id'
|
||||
agent-browser extract-to --from network --match usersearch --fields 'name,fans,note_count,red_id,url' --format csv --out ./users.csv
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
- `--from <network|dom|eval>`: 数据源。
|
||||
- `--match <pattern>`: 来源过滤(URL/事件名)。
|
||||
- `--query <JMESPath|JSONPath>`: 自定义提取表达式。
|
||||
- `--fields <a,b,c>`: 字段映射快捷写法。
|
||||
- `--dedupe-by <field>`: 去重键。
|
||||
- `--limit <n>`: 限制条数。
|
||||
- `--format <json|ndjson|csv>`: 输出格式。
|
||||
- `--out <path>`: 文件输出路径。
|
||||
|
||||
## 5.4 语义选择器与回退链
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser click --selector 'role=tab[name="用户"]' --fallback 'aria=用户,text=用户'
|
||||
agent-browser find --selector 'data-testid=user-tab' --fallback 'role=tab[name="用户"],text=用户'
|
||||
```
|
||||
|
||||
策略:
|
||||
|
||||
1. 主选择器失败后按 fallback 顺序重试。
|
||||
2. 日志打印每次尝试与失败原因。
|
||||
|
||||
## 5.5 反爬失败自动回退
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser request replay --on-status 401,403,406 --fallback page-action
|
||||
```
|
||||
|
||||
策略:
|
||||
|
||||
1. 直接请求失败后自动回退到页面行为触发。
|
||||
2. 自动复用 UA/Referer/Cookie Jar。
|
||||
3. 捕获最终有效响应并给出“回退成功/失败”日志。
|
||||
|
||||
## 5.6 会话快照与断点续抓
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser session snapshot save ./snapshots/xhs-20260304.json
|
||||
agent-browser crawl resume --snapshot ./snapshots/xhs-20260304.json --out ./users.csv
|
||||
```
|
||||
|
||||
快照最小字段:
|
||||
|
||||
- 当前 URL
|
||||
- 关键词/筛选参数
|
||||
- 已抓 user_id 集合摘要(可哈希分片)
|
||||
- 分页进度(page/scroll step)
|
||||
- 导出配置(fields/format/out)
|
||||
|
||||
## 6. 错误码设计(草案)
|
||||
|
||||
- `AB_NET_CAPTURE_BODY_UNAVAILABLE` (1001): 响应体不可用(被浏览器策略阻断或已释放)。
|
||||
- `AB_SCROLL_TIMEOUT_NO_PROGRESS` (1101): 滚动超时且无新增。
|
||||
- `AB_EXTRACT_QUERY_INVALID` (1201): 提取表达式语法错误。
|
||||
- `AB_EXTRACT_OUTPUT_FAILED` (1202): 导出失败(权限/路径不可写)。
|
||||
- `AB_SELECTOR_NOT_FOUND` (1301): 主选择器与 fallback 全部失败。
|
||||
- `AB_REQUEST_BLOCKED_406` (1406): 请求被风控拦截,且回退链路失败。
|
||||
- `AB_RESUME_SNAPSHOT_INVALID` (1501): 快照损坏或版本不兼容。
|
||||
|
||||
要求:
|
||||
|
||||
1. CLI 退出码与错误码可映射。
|
||||
2. 错误输出提供 `hint`(下一步建议命令)。
|
||||
|
||||
## 7. 日志与可观测性
|
||||
|
||||
默认人类可读,开启 `--log-format json` 输出结构化日志。
|
||||
|
||||
JSON 日志字段:
|
||||
|
||||
- `ts`
|
||||
- `level`
|
||||
- `session_id`
|
||||
- `command`
|
||||
- `event`
|
||||
- `step`
|
||||
- `url`
|
||||
- `status`
|
||||
- `error_code`
|
||||
- `message`
|
||||
- `hint`
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{"ts":"2026-03-04T10:11:22.123Z","level":"INFO","command":"scroll-collect","event":"step","step":12,"new_items":15,"total_items":180}
|
||||
{"ts":"2026-03-04T10:13:01.001Z","level":"WARN","command":"request replay","event":"fallback","status":406,"message":"direct request blocked, fallback to page-action"}
|
||||
```
|
||||
|
||||
## 8. 文档与帮助信息更新要求
|
||||
|
||||
当功能落地时,需要同步更新以下位置(按仓库规范):
|
||||
|
||||
1. `cli/src/output.rs`(`--help`、示例、环境变量)
|
||||
2. `README.md`(命令选项、样例)
|
||||
3. `skills/agent-browser/SKILL.md`(Agent 工作流)
|
||||
4. `docs/src/app/`(新增/更新 MDX 页面,表格使用 HTML `<table>`)
|
||||
5. 对应源码内联注释
|
||||
|
||||
## 9. 验收用例(首批)
|
||||
|
||||
1. `network capture` 能稳定保存目标接口完整 request/response body。
|
||||
2. 设置 `--max-body-bytes` 后被截断记录带 `truncated=true`。
|
||||
3. `scroll-collect --pages 5` 精确执行 5 轮并退出。
|
||||
4. `scroll-collect --until no-new-items` 在连续空增量 N 轮后退出。
|
||||
5. `extract-to ... --format csv` 产出可打开 CSV 且列名正确。
|
||||
6. `extract --dedupe-by user_id` 去重结果稳定。
|
||||
7. selector 主规则失败时,fallback 生效并成功点击。
|
||||
8. 对 406 场景触发自动回退并成功捕获有效响应。
|
||||
9. 回退失败时返回 `AB_REQUEST_BLOCKED_406` 且提供 hint。
|
||||
10. `session snapshot save/load` 前后任务可恢复。
|
||||
11. `crawl resume` 不重复导出已抓 ID。
|
||||
12. `--log-format json` 日志字段完整,便于机器消费。
|
||||
|
||||
## 10. 里程碑建议
|
||||
|
||||
1. M1(1 周): `network capture` + `scroll-collect`。
|
||||
2. M2(1 周): `extract-to` + selector fallback。
|
||||
3. M3(1 周): 406 回退链路 + 文档补全。
|
||||
4. M4(1 周): snapshot/resume + 稳定性打磨。
|
||||
|
||||
## 11. 风险与缓解
|
||||
|
||||
1. 平台策略变化导致规则失效。
|
||||
缓解: 增加站点适配层与策略开关,保留回退日志。
|
||||
2. 响应体过大带来内存与 IO 压力。
|
||||
缓解: 流式写入 NDJSON + 截断阈值。
|
||||
3. 通用提取表达式学习成本高。
|
||||
缓解: 提供字段模板与场景 presets。
|
||||
|
||||
## 12. 开放问题
|
||||
|
||||
1. `extract` 表达式标准优先 JSONPath 还是 JMESPath?
|
||||
2. `session snapshot` 是否需要加密(含 cookie 元信息)?
|
||||
3. 是否提供站点模板(如 `preset xiaohongshu-user-search`)以降低上手成本?
|
||||
@@ -0,0 +1,192 @@
|
||||
# 浏览器自动化攻防方案设计:检测模型与分层控制面
|
||||
|
||||
本文聚焦浏览器自动化的攻防方案设计,按两个部分组织:
|
||||
|
||||
1. **原理**:风险评分系统如何形成结论
|
||||
2. **控制面**:如何用分层设计降低风险与波动
|
||||
|
||||
本文不包含命令行操作与工程实现步骤。
|
||||
|
||||
Turnstile 专题内容见:
|
||||
[Cloudflare Turnstile 攻防方案设计:系统原理与控制面](https://blog.misonote.com/zh/posts/cloudflare-turnstile-stability-principles/)
|
||||
|
||||
---
|
||||
|
||||
## 一、原理
|
||||
|
||||
### 1.1 风险评分不是单点命中
|
||||
|
||||
高风控站点的“是否挑战/是否降权”通常来自多维评分,而不是某一条规则的二元判断。
|
||||
|
||||
主要输入维度:
|
||||
|
||||
1. **一致性**:同一身份在不同表面是否互相矛盾
|
||||
2. **稀有性**:低频异常组合是否出现
|
||||
3. **时序性**:行为时间序列是否呈机械统计特征
|
||||
4. **执行完整性**:关键链路(挑战脚本、跨域资源、worker)是否被破坏
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["环境与行为"] --> B["一致性评分"]
|
||||
A --> C["稀有性评分"]
|
||||
A --> D["时序评分"]
|
||||
A --> E["执行完整性评分"]
|
||||
B --> F["综合风险"]
|
||||
C --> F
|
||||
D --> F
|
||||
E --> F
|
||||
F --> G{"放行/挑战/限流"}
|
||||
```
|
||||
|
||||
### 1.2 一致性:约束集合而非单点修饰
|
||||
|
||||
一致性问题的本质是“同一身份在多个观测面上的约束必须同时成立”。
|
||||
|
||||
#### 1.2.1 约束集合示意
|
||||
|
||||
可以把身份一致性建模为“约束图”:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
UA["UA 字符串"] --> UACH["UA-CH / userAgentMetadata"]
|
||||
UA --> LangH["Accept-Language"]
|
||||
LangH --> LangJS["navigator.language(s)"]
|
||||
LangJS --> Intl["Intl locale/timeZone"]
|
||||
Plat["platform"] --> Rend["渲染能力/WebGL"]
|
||||
Rend --> Win["窗口/屏幕参数"]
|
||||
UACH --> Plat
|
||||
```
|
||||
|
||||
图中每条边表示“两个表面必须相互一致”,否则会形成冲突分值。
|
||||
|
||||
#### 1.2.2 典型冲突类型
|
||||
|
||||
- UA 显示平台/版本与 UA-CH 不一致
|
||||
- `Accept-Language` 与 `navigator.languages` 不一致
|
||||
- `Intl` 时区与偏移/地区推断不一致
|
||||
- 设备声明与渲染能力组合异常
|
||||
|
||||
工程含义:
|
||||
|
||||
- 修一个点可能打破另一个点
|
||||
- 设计顺序应是“先定约束集合,再决定每个表面如何满足约束”
|
||||
|
||||
### 1.3 稀有性:组合风险而非单值风险
|
||||
|
||||
稀有性来自“低频组合”,其危险性来自共现而非单项。
|
||||
|
||||
可以将稀有性理解为“联合分布”偏离:
|
||||
|
||||
- 单项偏离:可被容忍
|
||||
- 多项共现偏离:风险迅速累积
|
||||
|
||||
工程含义:
|
||||
|
||||
- 目标是减少低频组合在同一会话内叠加
|
||||
- 目标不是拟合某个固定画像
|
||||
|
||||
### 1.4 时序性:统计特征而非行为语义
|
||||
|
||||
行为检测通常关注统计分布特征:
|
||||
|
||||
- 低方差:动作间隔过于稳定
|
||||
- 强周期:间隔呈固定节奏
|
||||
- 强同步:不同类型动作间隔一致
|
||||
|
||||
工程含义:
|
||||
|
||||
- 行为治理的目标是“分布塑形”(variance/jitter/backoff)
|
||||
- 行为治理不是“添加更多动作”
|
||||
|
||||
### 1.5 执行完整性:上游条件
|
||||
|
||||
执行完整性属于“系统是否能正确运行”的前置条件。
|
||||
|
||||
- challenge 脚本、跨域 iframe、跨域 worker 的语义被破坏时,失败率会显著上升
|
||||
- 此类失败可能与“是否被识别”为不同类别的问题
|
||||
|
||||
工程原则:
|
||||
|
||||
> 执行链路保护优先于信号修饰。
|
||||
|
||||
---
|
||||
|
||||
## 二、控制面(分层设计)
|
||||
|
||||
### 2.1 控制面总览
|
||||
|
||||
攻防方案可以拆为四层控制面:
|
||||
|
||||
1. **启动控制**:治理启动早期显式风险
|
||||
2. **协议控制**:治理协议层身份一致性
|
||||
3. **运行时控制**:治理页面脚本可观测表面
|
||||
4. **行为与会话控制**:治理时序分布与上下文漂移
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["启动控制"] --> B["协议控制"]
|
||||
B --> C["运行时控制"]
|
||||
C --> D["行为与会话控制"]
|
||||
D --> E["一致性与稳定性"]
|
||||
```
|
||||
|
||||
### 2.2 启动控制
|
||||
|
||||
目标:降低会话早期显式风险。
|
||||
|
||||
设计约束:
|
||||
|
||||
- 只处理高置信度自动化标识
|
||||
- 避免引入与协议层/运行时层不一致的改动
|
||||
|
||||
### 2.3 协议控制
|
||||
|
||||
目标:将身份约束集合落实到协议层输出。
|
||||
|
||||
设计要点:
|
||||
|
||||
- 将 UA 与 UA-CH 视为同一约束集合的不同投影
|
||||
- 覆盖范围需要与目标(页面/worker/子目标)一致
|
||||
|
||||
### 2.4 运行时控制
|
||||
|
||||
目标:覆盖高频探测面,同时保证不破坏执行语义。
|
||||
|
||||
设计要点:
|
||||
|
||||
- 优先治理高频、可解释的探测路径
|
||||
- 对跨域挑战链路对象设置严格注入边界
|
||||
|
||||
### 2.5 行为与会话控制
|
||||
|
||||
目标:塑形时间分布,减少上下文漂移。
|
||||
|
||||
设计要点:
|
||||
|
||||
- 行为治理以统计分布为目标(variance/jitter/backoff)
|
||||
- 会话治理以一致上下文为目标(避免身份漂移)
|
||||
|
||||
### 2.6 挑战场景控制面摘要(Turnstile)
|
||||
|
||||
Turnstile 场景下的关键控制面可抽象为:
|
||||
|
||||
1. 能力令牌语义:服务端验证、有限时效、单次消费
|
||||
2. 作用域收缩:`hostname/action/cdata` 收缩滥用空间
|
||||
3. 执行链路保护:跨域脚本/iframe/worker 语义保护
|
||||
4. 摩擦与安全分离:clearance 属于体验层,不替代安全决策层
|
||||
|
||||
该摘要用于将 Turnstile 纳入统一控制面框架;细节见专题文章。
|
||||
|
||||
---
|
||||
|
||||
## 三、方案设计优先级
|
||||
|
||||
控制面设计通常按以下优先级推进:
|
||||
|
||||
1. 执行完整性(保证链路可运行)
|
||||
2. 一致性约束集合(消除跨表面矛盾)
|
||||
3. 稀有性控制(避免低频组合叠加)
|
||||
4. 时序分布塑形(降低机械统计特征)
|
||||
5. 体验优化(降低重复挑战摩擦)
|
||||
|
||||
该顺序的含义是先保证“系统正确性”,再优化“稳定性与摩擦”。
|
||||
@@ -0,0 +1,250 @@
|
||||
# Cloudflare Turnstile 攻防方案设计:系统原理与控制面
|
||||
|
||||
本文聚焦 Turnstile 的攻防方案设计:
|
||||
|
||||
1. **系统原理**:token 的安全语义、挑战执行链路、风险评分的输入输出
|
||||
2. **控制面设计**:在不同攻击面下,哪些约束是必要的、哪些约束容易引入副作用
|
||||
|
||||
本文不包含命令行操作与工程实现步骤。
|
||||
|
||||
---
|
||||
|
||||
## 一、系统原理
|
||||
|
||||
### 1.1 Turnstile 是“能力令牌”系统
|
||||
|
||||
Turnstile 的本质是签发一个短生命周期、单次消费的能力令牌(capability token)。
|
||||
|
||||
- **签发端**:浏览器端完成挑战执行后获得 token
|
||||
- **消费端**:业务服务端通过 Siteverify 验证 token 并决定是否放行
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["浏览器端挑战执行"] --> B["token"]
|
||||
B --> C["业务服务端"]
|
||||
C --> D["Siteverify"]
|
||||
D --> E{"放行/拒绝"}
|
||||
```
|
||||
|
||||
关键含义:
|
||||
|
||||
- 前端任何“通过”状态都不是业务放行条件
|
||||
- 业务放行条件是“token 被正确消费”
|
||||
|
||||
### 1.2 Token 的三条安全语义
|
||||
|
||||
token 的安全语义可以抽象为三条约束:
|
||||
|
||||
1. **必须服务端验证**:不允许仅以前端回调作为依据
|
||||
2. **有限时效**:token 超过时效窗口即失效
|
||||
3. **单次消费**:同一 token 重复消费应失败
|
||||
|
||||
这三条语义分别封装了三个常见攻击目标:
|
||||
|
||||
- 伪通过:绕过服务端验证
|
||||
- 延迟提交:绕过时效窗口
|
||||
- 重放/并发:绕过单次消费
|
||||
|
||||
### 1.3 挑战执行链路是“跨域执行系统”
|
||||
|
||||
Turnstile 的 token 产生依赖多组件协作,且跨域链路占主导:
|
||||
|
||||
- `api.js` 脚本
|
||||
- challenge iframe
|
||||
- challenge worker
|
||||
- 跨域资源请求
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["加载 api.js"] --> B["创建 iframe"]
|
||||
B --> C["执行 worker"]
|
||||
C --> D["收集信号 + 风险评估"]
|
||||
D --> E["签发 token"]
|
||||
```
|
||||
|
||||
该链路的工程含义:
|
||||
|
||||
- 任何对跨域脚本/iframe/worker 的语义改写,都可能导致 token 生成失败或质量下降
|
||||
- token 失败不一定意味着“被识别”,也可能是“链路被破坏”
|
||||
|
||||
### 1.4 风险评分:输入不是“真假”,而是“自洽程度”
|
||||
|
||||
挑战执行阶段会收集环境与行为信号,形成风险评分。
|
||||
|
||||
- **信号输入**:环境一致性(UA/UA-CH、语言/时区、渲染能力、能力暴露)
|
||||
- **行为输入**:时序分布(方差、周期性、同步性)
|
||||
|
||||
风险评分的关键不是“拟合某种固定画像”,而是“同一身份在多表面是否自洽”。
|
||||
|
||||
### 1.5 作用域绑定:hostname / action / cdata
|
||||
|
||||
服务端校验时提供用于绑定业务语义的字段:
|
||||
|
||||
- `hostname`:token 允许的站点作用域
|
||||
- `action`:token 允许的动作作用域
|
||||
- `cdata`:token 允许的上下文作用域
|
||||
|
||||
这些字段的作用是“收缩 token 可被滥用的范围”,而不是“提高通过率”。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["token"] --> B["hostname 作用域"]
|
||||
A --> C["action 作用域"]
|
||||
A --> D["cdata 作用域"]
|
||||
B --> E["降低站外盗用收益"]
|
||||
C --> F["降低动作错配收益"]
|
||||
D --> G["降低跨流程重放收益"]
|
||||
```
|
||||
|
||||
### 1.6 Token 状态机(能力令牌视角)
|
||||
|
||||
从能力令牌视角,token 生命周期可抽象为:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Issued: challenge ok
|
||||
Issued --> Consumed: siteverify ok
|
||||
Issued --> Expired: time window
|
||||
Issued --> Rejected: binding mismatch
|
||||
Issued --> Replayed: reused
|
||||
Replayed --> Rejected
|
||||
Expired --> Rejected
|
||||
Consumed --> [*]
|
||||
```
|
||||
|
||||
设计目标是让“非法路径”快速失败,并且失败类型可被服务端语义区分。
|
||||
|
||||
### 1.7 攻击树(高层)
|
||||
|
||||
Turnstile 的主要攻击目标可以抽象为:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["绕过业务动作门禁"] --> B["伪造或跳过服务端验证"]
|
||||
A --> C["重放 token"]
|
||||
A --> D["扩大 token 作用域"]
|
||||
A --> E["破坏挑战执行以制造降级路径"]
|
||||
C --> C1["并发提交"]
|
||||
C --> C2["延迟提交"]
|
||||
D --> D1["Any Hostname"]
|
||||
D --> D2["action/cdata 缺失"]
|
||||
```
|
||||
|
||||
该攻击树强调设计重点:
|
||||
|
||||
- 安全决策必须在服务端闭环
|
||||
- token 必须被作用域收缩并按语义消费
|
||||
|
||||
---
|
||||
|
||||
## 二、控制面设计(攻防视角)
|
||||
|
||||
### 2.1 控制面分层
|
||||
|
||||
Turnstile 防线可以分为四层控制面:
|
||||
|
||||
1. **挑战执行控制**:保证脚本/iframe/worker 跨域链路完整
|
||||
2. **服务端消费控制**:保证 token 的语义被正确消费
|
||||
3. **作用域控制**:收缩 `hostname/action/cdata` 的可用范围
|
||||
4. **摩擦控制**:clearance 用于降低挑战摩擦(不作为安全决策依据)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["挑战执行控制"] --> E["token 可生成"]
|
||||
A --> F["token 质量"]
|
||||
B["服务端消费控制"] --> G["安全决策闭环"]
|
||||
C["作用域控制"] --> H["滥用收益收缩"]
|
||||
D["摩擦控制"] --> I["挑战频率下降"]
|
||||
```
|
||||
|
||||
### 2.2 挑战执行控制:跨域语义保护优先
|
||||
|
||||
挑战执行链路对跨域执行语义高度敏感。
|
||||
|
||||
原则:
|
||||
|
||||
- 跨域脚本/iframe/worker 避免语义改写
|
||||
- 所有指纹修饰必须先满足“不破坏挑战执行”这一硬约束
|
||||
|
||||
该原则的工程含义:
|
||||
|
||||
- “执行完整性”是上游条件
|
||||
- “信号修饰”是下游优化
|
||||
|
||||
### 2.3 服务端消费控制:把 token 当作能力消费
|
||||
|
||||
服务端消费控制的设计关键在于“放行条件定义”,而不是“接口调用细节”。
|
||||
|
||||
放行条件应体现三类约束:
|
||||
|
||||
- 真实性:校验 `success`
|
||||
- 作用域:校验 `hostname`
|
||||
- 语义绑定:校验 `action/cdata`
|
||||
|
||||
并且必须贯彻 token 的两个安全语义:
|
||||
|
||||
- 时效性:过期拒绝
|
||||
- 单次性:重放拒绝
|
||||
|
||||
从攻防角度,该层解决的是“绕过与重放”。
|
||||
|
||||
### 2.4 作用域控制:Hostname Management 与 Any Hostname
|
||||
|
||||
Hostname 管理解决“站外盗用”的攻击面。
|
||||
|
||||
- 启用 Hostname Management:收缩 token 可用站点范围
|
||||
- 启用 Any Hostname:扩大 token 可用站点范围
|
||||
|
||||
设计结论:
|
||||
|
||||
- Any Hostname 不是“更灵活”,而是“扩大攻击面”,必须用更强的服务端约束做补偿控制(来源域白名单 + 业务绑定)。
|
||||
|
||||
### 2.5 摩擦控制:Pre-clearance 与 cf_clearance 的边界
|
||||
|
||||
Pre-clearance 通过后可产生 clearance,用于后续 WAF 挑战联动。
|
||||
|
||||
边界定义:
|
||||
|
||||
- clearance 用于体验层(降低重复挑战摩擦)
|
||||
- Siteverify 用于安全决策层(业务放行依据)
|
||||
|
||||
将两者混用会引入“体验信号替代安全信号”的设计缺陷。
|
||||
|
||||
### 2.6 高对抗场景:代理池与设备关联
|
||||
|
||||
在代理池与分布式滥用场景中,单一 IP 维度约束容易失效。
|
||||
|
||||
设计方向是引入更稳定的关联维度(例如设备级 ephemeral id),用于聚类与阈值策略。
|
||||
|
||||
该层属于平台能力与业务风控的交界:
|
||||
|
||||
- 平台提供关联信号
|
||||
- 业务定义动作分层、阈值与处置策略
|
||||
|
||||
---
|
||||
|
||||
## 三、方案设计优先级
|
||||
|
||||
Turnstile 攻防设计通常按以下优先级推进:
|
||||
|
||||
1. 服务端消费语义闭环(真实性 + 作用域 + 绑定 + 单次性 + 时效性)
|
||||
2. 挑战执行链路完整性(跨域语义保护)
|
||||
3. 信号一致性(减少跨字段矛盾)
|
||||
4. 行为时序(降低机械分布)
|
||||
5. 体验优化(clearance 等摩擦控制)
|
||||
|
||||
该顺序的含义是先定义“正确的安全决策”,再优化“挑战摩擦与通过率波动”。
|
||||
|
||||
---
|
||||
|
||||
## 官方参考(概念与配置)
|
||||
|
||||
- Widgets: <https://developers.cloudflare.com/turnstile/concepts/widget/>
|
||||
- Widget configurations: <https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/>
|
||||
- Server-side validation: <https://developers.cloudflare.com/turnstile/get-started/server-side-validation/>
|
||||
- CSP: <https://developers.cloudflare.com/turnstile/reference/content-security-policy/>
|
||||
- Hostname management: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/>
|
||||
- Any Hostname: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/any-hostname/>
|
||||
- Pre-clearance: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/pre-clearance/>
|
||||
- Cloudflare clearance: <https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/>
|
||||
- Ephemeral IDs: <https://developers.cloudflare.com/turnstile/additional-configuration/ephemeral-id/>
|
||||
@@ -0,0 +1,97 @@
|
||||
# agent-browser 与 agent-browser-stealth:能力差异与选型
|
||||
|
||||
本文给出 `agent-browser` 与 `agent-browser-stealth` 的技术差异、适用场景和升级验证步骤。
|
||||
|
||||
项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser)
|
||||
|
||||
---
|
||||
|
||||
## 1. 定位差异
|
||||
|
||||
- `agent-browser`:标准浏览器自动化能力
|
||||
- `agent-browser-stealth`:在标准自动化能力基础上,增加反检测与高风控场景稳定性能力
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心能力对比
|
||||
|
||||
| 维度 | agent-browser | agent-browser-stealth |
|
||||
| --- | --- | --- |
|
||||
| 自动化基础能力 | 支持 | 支持 |
|
||||
| 指纹一致性治理 | 基础 | 多层(launch/CDP/init-script) |
|
||||
| 高风控站点稳定性 | 一般 | 更高 |
|
||||
| 会话连续性(附着现有浏览器) | 支持 | 支持,默认附着策略更明确 |
|
||||
| Cloudflare/Turnstile 回归工具 | 无专用脚本 | `check:turnstile-testkey` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cloudflare/Turnstile 相关能力(v0.15.2-fork.2+)
|
||||
|
||||
### 3.1 挑战链路保护
|
||||
|
||||
- 同源 worker 注入保留
|
||||
- 跨域 challenge worker 不做注入改写
|
||||
- 降低 challenge worker 执行异常概率
|
||||
|
||||
### 3.2 导航等待策略
|
||||
|
||||
`open/navigate` 支持:
|
||||
|
||||
- `--wait-until load`
|
||||
- `--wait-until domcontentloaded`
|
||||
- `--wait-until networkidle`
|
||||
|
||||
挑战页建议优先 `domcontentloaded`,减少 `load` 阶段超时误判。
|
||||
|
||||
### 3.3 确定性回归
|
||||
|
||||
提供官方 test key 回归脚本:
|
||||
|
||||
```bash
|
||||
pnpm run check:turnstile-testkey
|
||||
```
|
||||
|
||||
通过特征:输出 `XXXX.DUMMY.TOKEN.XXXX`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 适用场景
|
||||
|
||||
优先使用 `agent-browser-stealth` 的场景:
|
||||
|
||||
1. 目标站点存在挑战页/验证码/限流
|
||||
2. 自动化链路对稳定性要求高
|
||||
3. 需要长期回归验证与版本门禁
|
||||
|
||||
使用 `agent-browser` 的场景:
|
||||
|
||||
1. 低风控站点
|
||||
2. 以基础自动化能力验证为主
|
||||
|
||||
---
|
||||
|
||||
## 5. 升级验证步骤
|
||||
|
||||
```bash
|
||||
# 1) 检查版本
|
||||
agent-browser -V
|
||||
|
||||
# 2) 关闭旧 daemon,避免版本漂移
|
||||
agent-browser --session default close
|
||||
|
||||
# 3) 运行确定性回归
|
||||
pnpm run check:turnstile-testkey
|
||||
|
||||
# 4) 可选:真实站点回归
|
||||
agent-browser --wait-until domcontentloaded open https://www.anyviewer.com/cloudflare.html
|
||||
```
|
||||
|
||||
如果启用域名白名单(`AGENT_BROWSER_ALLOWED_DOMAINS`),需包含 `challenges.cloudflare.com`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 结论
|
||||
|
||||
`agent-browser-stealth` 适用于高风控与稳定性敏感场景;`agent-browser` 适用于标准自动化场景。
|
||||
选型建议按目标站点风控强度与回归要求决定。
|
||||
|
||||
@@ -4,6 +4,62 @@ export const metadata = pageMetadata("changelog")
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.16.0
|
||||
|
||||
<p className="text-[#888] text-sm">March 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Native Rust daemon (experimental).** A pure Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating Node.js and Playwright dependencies entirely. Enable with `--native`, `AGENT_BROWSER_NATIVE=1`, or `"native": true` in your config file. Supports 150+ commands with full parity to the default Node.js daemon.
|
||||
|
||||
```bash
|
||||
# Via flag
|
||||
agent-browser --native open example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
Or add to `agent-browser.json`:
|
||||
|
||||
```json
|
||||
{"native": true}
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th></th><th>Default (Node.js)</th><th>Native (<code>--native</code>)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Runtime</strong></td><td>Node.js + Playwright</td><td>Pure Rust binary</td></tr>
|
||||
<tr><td><strong>Protocol</strong></td><td>Playwright protocol</td><td>Direct CDP / WebDriver</td></tr>
|
||||
<tr><td><strong>Install size</strong></td><td>Larger (Node.js + npm deps)</td><td>Smaller (single binary)</td></tr>
|
||||
<tr><td><strong>Browser support</strong></td><td>Chromium, Firefox, WebKit</td><td>Chromium, Safari (via WebDriver)</td></tr>
|
||||
<tr><td><strong>Stability</strong></td><td>Stable</td><td>Experimental</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### What's Supported
|
||||
|
||||
All core commands work in native mode: navigation, interaction (click, fill, type, press, hover, scroll, drag), observation (snapshot, screenshot, eval), state management (cookies, storage, state save/load), tabs, emulation (viewport, device, timezone, locale, geolocation), streaming, diffing, recording, and profiling.
|
||||
|
||||
The native daemon also includes a WebDriver backend for Safari and iOS support.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Firefox and WebKit are not yet supported (Chromium and Safari only)
|
||||
- Playwright trace format is not available (uses Chrome's built-in tracing)
|
||||
- HAR export is not available
|
||||
- Network route interception uses CDP Fetch domain instead of Playwright's route API
|
||||
- The native and Node.js daemons share the same session socket. Use `agent-browser close` before switching between modes.
|
||||
|
||||
See the [Native Mode](/native-mode) page for full details.
|
||||
|
||||
---
|
||||
|
||||
## v0.15.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
@@ -4,6 +4,8 @@ export const metadata = pageMetadata('commands');
|
||||
|
||||
# Commands
|
||||
|
||||
Executable aliases: `agent-browser`, `agent-browser-stealth`, `abs`.
|
||||
|
||||
## Core
|
||||
|
||||
```bash
|
||||
@@ -33,6 +35,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 + sourceURL + tab-group plugin health
|
||||
agent-browser --version # Show CLI version
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
```
|
||||
@@ -116,7 +119,7 @@ agent-browser wait --download [path] # Wait for download
|
||||
Control how `open`/`navigate` handles verification or captcha interstitials:
|
||||
|
||||
```bash
|
||||
agent-browser --risk-mode warn open https://example.com # default: retry and warn with riskSignals
|
||||
agent-browser --risk-mode warn open https://example.com # default: wait for auto-clear, then retry/warn with riskSignals
|
||||
agent-browser --risk-mode block open https://example.com # fail fast on detection
|
||||
agent-browser --risk-mode off open https://example.com # disable detection/retry
|
||||
```
|
||||
@@ -130,6 +133,32 @@ 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.
|
||||
|
||||
## Tab grouping
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com
|
||||
# CDP mode groups tabs when tab-group plugin is installed
|
||||
|
||||
# Override the default group title
|
||||
agent-browser --tab-group "My Agent Group" open https://example.com
|
||||
```
|
||||
|
||||
CDP mode uses a browser extension handshake to group tabs.
|
||||
|
||||
- Extension available: tabs are grouped by `session`.
|
||||
- Extension missing/unavailable: silent no-op (commands still succeed).
|
||||
- Default titles:
|
||||
- `default` session: `Agent Browser Stealth`
|
||||
- non-default: `Agent Browser Stealth • <session>`
|
||||
- Extension side panel (`agent-browser-stealth`) also provides:
|
||||
- Session window isolation and deterministic group colors.
|
||||
- `Keep Only This`, `Focus`, `Clean Empty Groups` quick actions.
|
||||
- Toggle switches for strict isolation / activation guard / auto-clean.
|
||||
- Session allowlist editing (domain fallback to `about:blank` when violated).
|
||||
- Download routing to `agent-browser-stealth/<session>/...`.
|
||||
- Use `--tab-group` / `AGENT_BROWSER_TAB_GROUP` for base title.
|
||||
- Use `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` (or `--tab-group-plugin-id`) to override expected extension ID.
|
||||
|
||||
## Mouse
|
||||
|
||||
```bash
|
||||
@@ -226,6 +255,8 @@ 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 + sourceURL + plugin handshake status
|
||||
pnpm run check:turnstile-testkey # Deterministic Turnstile smoke check (official test key)
|
||||
```
|
||||
|
||||
## State management
|
||||
@@ -260,7 +291,7 @@ agent-browser reload # Reload page
|
||||
|
||||
```bash
|
||||
--session <name> # Isolated browser session
|
||||
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
|
||||
--session-name <name> # Auto-save/restore session state (defaults to --session when omitted)
|
||||
--state <path> # Load storage state from JSON file
|
||||
--headers <json> # HTTP headers scoped to URL's origin
|
||||
--executable-path <path> # Custom browser executable
|
||||
@@ -280,6 +311,9 @@ agent-browser reload # Reload page
|
||||
--headed # Show browser window (not headless)
|
||||
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
|
||||
--auto-connect # Auto-discover and connect to running Chrome
|
||||
--tab-group <name> # Base title for agent tab groups (CDP plugin mode)
|
||||
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
|
||||
--wait-until <mode> # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle)
|
||||
--debug # Debug output (includes stealth connection type + capabilities)
|
||||
```
|
||||
|
||||
|
||||
@@ -274,6 +274,24 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>tabGroup</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--tab-group</code>
|
||||
</td>
|
||||
<td>string (base title for session tab grouping via CDP plugin handshake)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>tabGroupPluginId</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--tab-group-plugin-id</code>
|
||||
</td>
|
||||
<td>string (expected extension ID for tab-group plugin handshake)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>riskMode</code>
|
||||
@@ -299,6 +317,12 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
|
||||
`riskMode` defaults to `warn` when unset.
|
||||
|
||||
For tab grouping in CDP mode, grouping is best-effort through the extension handshake:
|
||||
extension available => grouped by session; extension missing/unavailable => silent no-op.
|
||||
|
||||
With the `agent-browser-stealth` extension installed, the side panel also exposes
|
||||
session window isolation controls, activation guard toggles, empty-group cleanup, and per-session allowlist policy editing.
|
||||
|
||||
## Common Configurations
|
||||
|
||||
### Local Development
|
||||
@@ -406,6 +430,24 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<td>Default directory for browser downloads.</td>
|
||||
<td>(temp directory)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_TAB_GROUP</code>
|
||||
</td>
|
||||
<td>Base title for tab grouping. Session suffix is appended automatically in CDP mode.</td>
|
||||
<td>
|
||||
<code>Agent Browser Stealth</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_TAB_GROUP_PLUGIN_ID</code>
|
||||
</td>
|
||||
<td>Expected extension ID for CDP tab-group plugin handshake.</td>
|
||||
<td>
|
||||
<code>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_RISK_MODE</code>
|
||||
@@ -431,8 +473,13 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<td>
|
||||
<code>AGENT_BROWSER_SESSION_NAME</code>
|
||||
</td>
|
||||
<td>Auto-save/load state persistence name.</td>
|
||||
<td>(none)</td>
|
||||
<td>
|
||||
Auto-save/load state persistence name (defaults to <code>AGENT_BROWSER_SESSION</code> when
|
||||
unset).
|
||||
</td>
|
||||
<td>
|
||||
(same as <code>AGENT_BROWSER_SESSION</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("native-mode")
|
||||
|
||||
# Native Mode (Experimental)
|
||||
|
||||
agent-browser includes an experimental native Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating the Node.js and Playwright dependencies entirely.
|
||||
|
||||
## Enabling Native Mode
|
||||
|
||||
Native mode is opt-in. Enable it with the `--native` flag or the `AGENT_BROWSER_NATIVE` environment variable.
|
||||
|
||||
### CLI Flag
|
||||
|
||||
```bash
|
||||
agent-browser --native open example.com
|
||||
agent-browser --native snapshot
|
||||
agent-browser --native close
|
||||
```
|
||||
|
||||
### Environment Variable
|
||||
|
||||
Set `AGENT_BROWSER_NATIVE=1` to avoid passing the flag on every command:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
agent-browser snapshot
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
### Config File
|
||||
|
||||
Add `"native": true` to your `agent-browser.json`:
|
||||
|
||||
```json
|
||||
{"native": true}
|
||||
```
|
||||
|
||||
## Architecture Comparison
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th></th><th>Default (Node.js)</th><th>Native (<code>--native</code>)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Runtime</strong></td><td>Node.js + Playwright</td><td>Pure Rust binary</td></tr>
|
||||
<tr><td><strong>Protocol</strong></td><td>Playwright protocol</td><td>Direct CDP / WebDriver</td></tr>
|
||||
<tr><td><strong>Install size</strong></td><td>Larger (Node.js + npm deps)</td><td>Smaller (single binary)</td></tr>
|
||||
<tr><td><strong>Browser support</strong></td><td>Chromium, Firefox, WebKit</td><td>Chromium, Safari (via WebDriver)</td></tr>
|
||||
<tr><td><strong>Stability</strong></td><td>Stable</td><td>Experimental</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## What Works
|
||||
|
||||
All core commands are supported in native mode:
|
||||
|
||||
- Navigation: `open`, `back`, `forward`, `reload`
|
||||
- Interaction: `click`, `fill`, `type`, `press`, `hover`, `select`, `check`, `uncheck`, `scroll`, `focus`, `clear`, `upload`, `drag`
|
||||
- Observation: `snapshot`, `screenshot`, `eval`, `get text/html/value/attr/count/box/styles`, `is visible/enabled/checked`
|
||||
- State: `cookies get/set/clear`, `storage local/session`, `state save/load/list`
|
||||
- Tabs: `tab new/list/close`, tab switching
|
||||
- Emulation: `set viewport`, `set device`, `set geo`, user agent, timezone, locale
|
||||
- Streaming: WebSocket screencast and remote input
|
||||
- Diffing: `diff snapshot`, `diff url`
|
||||
- Recording: `record start/stop`
|
||||
- Profiling: `profiler start/stop`, `trace start/stop`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Firefox and WebKit** are not yet supported (Chromium and Safari only)
|
||||
- **Playwright trace format** is not available (native tracing uses Chrome's built-in tracing)
|
||||
- **HAR export** is not available
|
||||
- **Network route interception** uses CDP Fetch domain instead of Playwright's route API
|
||||
|
||||
## Switching Between Modes
|
||||
|
||||
The native daemon and Node.js daemon share the same session socket. You cannot run both simultaneously for the same session. Close the current daemon before switching:
|
||||
|
||||
```bash
|
||||
agent-browser close
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
```
|
||||
@@ -14,6 +14,8 @@ brew install agent-browser # macOS
|
||||
npx agent-browser-stealth open example.com
|
||||
```
|
||||
|
||||
Executable aliases after install: `agent-browser`, `agent-browser-stealth`, and `abs`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Agent-first** - Compact text output uses fewer tokens than JSON, designed for AI context efficiency
|
||||
@@ -61,7 +63,8 @@ has a unique ref like `@e1`, `@e2`. This provides:
|
||||
Client-daemon architecture for optimal performance:
|
||||
|
||||
1. **Rust CLI** - Parses commands, communicates with daemon
|
||||
2. **Node.js Daemon** - Manages Playwright browser instance
|
||||
2. **Node.js Daemon** (default) - Manages Playwright browser instance
|
||||
3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required
|
||||
|
||||
Daemon starts automatically and persists between commands.
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ export AGENT_BROWSER_SESSION_NAME=twitter
|
||||
agent-browser open twitter.com
|
||||
```
|
||||
|
||||
If `--session-name` is omitted, it defaults to `--session` (or `default`).
|
||||
|
||||
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
|
||||
|
||||
### Session name rules
|
||||
|
||||
@@ -37,6 +37,7 @@ export const navigation: NavSection[] = [
|
||||
{ name: "Profiler", href: "/profiler" },
|
||||
{ name: "iOS Simulator", href: "/ios" },
|
||||
{ name: "Security", href: "/security" },
|
||||
{ name: "Native Mode (Experimental)", href: "/native-mode" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
profiler: "Profiler",
|
||||
ios: "iOS Simulator",
|
||||
security: "Security",
|
||||
"native-mode": "Native Mode (Experimental)",
|
||||
changelog: "Changelog",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
(() => {
|
||||
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
const RESPONSE_TYPE = 'AB_TAB_GROUP_RESPONSE';
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
if (!data || data.type !== REQUEST_TYPE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
type: REQUEST_TYPE,
|
||||
nonce: data.nonce,
|
||||
session: data.session,
|
||||
groupTitle: data.groupTitle,
|
||||
pluginId: data.pluginId,
|
||||
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
chrome.runtime.sendMessage(request, (response) => {
|
||||
const lastError = chrome.runtime.lastError;
|
||||
if (lastError) {
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: false,
|
||||
error: lastError.message,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = response && typeof response === 'object' ? response : { ok: false };
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: payload.ok === true,
|
||||
extensionId:
|
||||
typeof payload.extensionId === 'string' && payload.extensionId.length > 0
|
||||
? payload.extensionId
|
||||
: chrome.runtime.id,
|
||||
groupId: typeof payload.groupId === 'number' ? payload.groupId : undefined,
|
||||
windowId: typeof payload.windowId === 'number' ? payload.windowId : undefined,
|
||||
color: typeof payload.color === 'string' ? payload.color : undefined,
|
||||
collapsed: payload.collapsed === true,
|
||||
policy:
|
||||
payload.policy && typeof payload.policy === 'object'
|
||||
? {
|
||||
enforced: payload.policy.enforced === true,
|
||||
blocked: payload.policy.blocked === true,
|
||||
reason:
|
||||
typeof payload.policy.reason === 'string' ? payload.policy.reason : undefined,
|
||||
}
|
||||
: undefined,
|
||||
riskHints: Array.isArray(payload.riskHints) ? payload.riskHints : undefined,
|
||||
error: typeof payload.error === 'string' ? payload.error : undefined,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.2.0",
|
||||
"description": "Session-aware tab grouping and coordination for CDP-driven agent-browser workflows.",
|
||||
"permissions": ["tabs", "tabGroups", "downloads", "storage", "sidePanel", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "agent-browser-stealth"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"run_at": "document_start",
|
||||
"match_about_blank": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
const PANEL_GET_STATE = 'AB_PANEL_GET_STATE';
|
||||
const PANEL_CLOSE_OTHER_TABS = 'AB_PANEL_CLOSE_OTHER_SESSION_TABS';
|
||||
const PANEL_FOCUS_SESSION = 'AB_PANEL_FOCUS_SESSION';
|
||||
const PANEL_CLEAN_EMPTY_GROUPS = 'AB_PANEL_CLEAN_EMPTY_GROUPS';
|
||||
const PANEL_SET_POLICY = 'AB_PANEL_SET_POLICY';
|
||||
const PANEL_SET_OPTIONS = 'AB_PANEL_SET_OPTIONS';
|
||||
|
||||
const DEFAULT_GROUP_TITLE = 'Agent Browser Stealth';
|
||||
const DOWNLOAD_ARCHIVE_ROOT = 'agent-browser-stealth';
|
||||
const STORAGE_POLICY_KEY = 'abSessionPoliciesV1';
|
||||
const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1';
|
||||
const CLEANUP_ALARM_NAME = 'ab-clean-empty-groups';
|
||||
const GROUP_COLORS = ['blue', 'green', 'pink', 'orange', 'purple', 'cyan', 'red', 'yellow'];
|
||||
const RISKY_TLDS = new Set(['zip', 'mov', 'click', 'top', 'gq', 'tk', 'country']);
|
||||
const RISKY_HOST_KEYWORDS = ['secure-login', 'account-verify', 'wallet-verify', 'airdrop-claim'];
|
||||
|
||||
const DEFAULT_EXTENSION_OPTIONS = {
|
||||
strictWindowIsolation: true,
|
||||
suppressCrossWindowActivation: true,
|
||||
autoCleanEmptyGroups: true,
|
||||
};
|
||||
|
||||
const sessionGroupCache = new Map();
|
||||
const sessionWindowMap = new Map();
|
||||
const tabSessionMap = new Map();
|
||||
const tabMetaById = new Map();
|
||||
const downloadEvents = [];
|
||||
const sessionPolicies = new Map();
|
||||
let extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS };
|
||||
|
||||
let bootstrapPromise = bootstrapState();
|
||||
|
||||
function normalizeSession(session) {
|
||||
if (typeof session !== 'string') return 'default';
|
||||
const trimmed = session.trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 64) : 'default';
|
||||
}
|
||||
|
||||
function normalizeGroupTitle(title) {
|
||||
if (typeof title !== 'string') return DEFAULT_GROUP_TITLE;
|
||||
const trimmed = title.trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 80) : DEFAULT_GROUP_TITLE;
|
||||
}
|
||||
|
||||
function normalizeAllowedDomains(domains) {
|
||||
if (!Array.isArray(domains)) return [];
|
||||
return domains
|
||||
.map((item) => (typeof item === 'string' ? item.trim().toLowerCase() : ''))
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 256);
|
||||
}
|
||||
|
||||
function parseHostname(rawUrl) {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return null;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
return parsed.hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function domainMatches(hostname, pattern) {
|
||||
if (!hostname || !pattern) return false;
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.slice(2);
|
||||
return hostname === suffix || hostname.endsWith(`.${suffix}`);
|
||||
}
|
||||
if (pattern.startsWith('.')) {
|
||||
const suffix = pattern.slice(1);
|
||||
return hostname === suffix || hostname.endsWith(pattern);
|
||||
}
|
||||
return hostname === pattern || hostname.endsWith(`.${pattern}`);
|
||||
}
|
||||
|
||||
function isDomainAllowed(hostname, patterns) {
|
||||
if (!hostname) return true;
|
||||
if (!patterns || patterns.length === 0) return true;
|
||||
return patterns.some((pattern) => domainMatches(hostname, pattern));
|
||||
}
|
||||
|
||||
function collectRiskHints(rawUrl, allowedDomains) {
|
||||
const hints = [];
|
||||
const hostname = parseHostname(rawUrl);
|
||||
if (!hostname) return hints;
|
||||
|
||||
if (allowedDomains.length > 0 && !isDomainAllowed(hostname, allowedDomains)) {
|
||||
hints.push(`domain-not-allowed:${hostname}`);
|
||||
}
|
||||
|
||||
const tld = hostname.split('.').pop();
|
||||
if (tld && RISKY_TLDS.has(tld)) {
|
||||
hints.push(`high-risk-tld:.${tld}`);
|
||||
}
|
||||
|
||||
for (const keyword of RISKY_HOST_KEYWORDS) {
|
||||
if (hostname.includes(keyword)) {
|
||||
hints.push(`suspicious-host-keyword:${keyword}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(hints)].slice(0, 10);
|
||||
}
|
||||
|
||||
function cacheKey(windowId, session) {
|
||||
return `${windowId}:${session}`;
|
||||
}
|
||||
|
||||
function sanitizeSegment(input, fallback = 'default') {
|
||||
const raw = typeof input === 'string' ? input : '';
|
||||
const cleaned = raw
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/\.+/g, '.')
|
||||
.trim();
|
||||
if (!cleaned) return fallback;
|
||||
return cleaned.slice(0, 80);
|
||||
}
|
||||
|
||||
function sanitizeFilename(filename, fallback = 'download.bin') {
|
||||
const name = typeof filename === 'string' ? filename.split('/').pop() : '';
|
||||
return sanitizeSegment(name, fallback);
|
||||
}
|
||||
|
||||
function pickColorForSession(session) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < session.length; i += 1) {
|
||||
hash = (hash * 31 + session.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return GROUP_COLORS[hash % GROUP_COLORS.length];
|
||||
}
|
||||
|
||||
function shouldCollapseGroup(session) {
|
||||
return session !== 'default';
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
try {
|
||||
const result = await chrome.storage.local.get([STORAGE_POLICY_KEY]);
|
||||
const entries = result?.[STORAGE_POLICY_KEY];
|
||||
if (!entries || typeof entries !== 'object') return;
|
||||
|
||||
for (const [session, domains] of Object.entries(entries)) {
|
||||
const normalizedSession = normalizeSession(session);
|
||||
sessionPolicies.set(normalizedSession, normalizeAllowedDomains(domains));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage load failures.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { ...DEFAULT_EXTENSION_OPTIONS };
|
||||
}
|
||||
return {
|
||||
strictWindowIsolation: raw.strictWindowIsolation !== false,
|
||||
suppressCrossWindowActivation: raw.suppressCrossWindowActivation !== false,
|
||||
autoCleanEmptyGroups: raw.autoCleanEmptyGroups !== false,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try {
|
||||
const result = await chrome.storage.local.get([STORAGE_OPTIONS_KEY]);
|
||||
extensionOptions = normalizeOptions(result?.[STORAGE_OPTIONS_KEY]);
|
||||
} catch {
|
||||
extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS };
|
||||
}
|
||||
}
|
||||
|
||||
async function persistOptions() {
|
||||
await chrome.storage.local.set({ [STORAGE_OPTIONS_KEY]: extensionOptions });
|
||||
}
|
||||
|
||||
async function setExtensionOptions(nextOptions) {
|
||||
extensionOptions = {
|
||||
...extensionOptions,
|
||||
...normalizeOptions(nextOptions),
|
||||
};
|
||||
await persistOptions();
|
||||
await syncCleanupAlarm();
|
||||
return extensionOptions;
|
||||
}
|
||||
|
||||
async function bootstrapState() {
|
||||
await loadPolicies();
|
||||
await loadOptions();
|
||||
await syncCleanupAlarm();
|
||||
}
|
||||
|
||||
async function persistPolicies() {
|
||||
const serialized = {};
|
||||
for (const [session, domains] of sessionPolicies.entries()) {
|
||||
serialized[session] = [...domains];
|
||||
}
|
||||
await chrome.storage.local.set({ [STORAGE_POLICY_KEY]: serialized });
|
||||
}
|
||||
|
||||
async function setSessionPolicy(session, allowedDomains) {
|
||||
const normalizedSession = normalizeSession(session);
|
||||
const normalizedDomains = normalizeAllowedDomains(allowedDomains);
|
||||
sessionPolicies.set(normalizedSession, normalizedDomains);
|
||||
await persistPolicies();
|
||||
}
|
||||
|
||||
function getSessionPolicy(session) {
|
||||
const normalizedSession = normalizeSession(session);
|
||||
return sessionPolicies.get(normalizedSession) ?? [];
|
||||
}
|
||||
|
||||
function updateTabMeta(tab) {
|
||||
if (!tab || typeof tab.id !== 'number') return;
|
||||
tabMetaById.set(tab.id, {
|
||||
id: tab.id,
|
||||
windowId: typeof tab.windowId === 'number' ? tab.windowId : -1,
|
||||
url: typeof tab.url === 'string' ? tab.url : '',
|
||||
title: typeof tab.title === 'string' ? tab.title : '',
|
||||
groupId: typeof tab.groupId === 'number' ? tab.groupId : -1,
|
||||
active: tab.active === true,
|
||||
lastSeenAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function pruneDownloadEvents() {
|
||||
const maxSize = 100;
|
||||
if (downloadEvents.length > maxSize) {
|
||||
downloadEvents.splice(0, downloadEvents.length - maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
function recordDownloadEvent(event) {
|
||||
downloadEvents.push({ ...event, timestamp: Date.now() });
|
||||
pruneDownloadEvents();
|
||||
}
|
||||
|
||||
function removeWindowCaches(windowId) {
|
||||
for (const key of [...sessionGroupCache.keys()]) {
|
||||
if (key.startsWith(`${windowId}:`)) {
|
||||
sessionGroupCache.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [session, mappedWindowId] of [...sessionWindowMap.entries()]) {
|
||||
if (mappedWindowId === windowId) {
|
||||
sessionWindowMap.delete(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSessionWindow(tabId, currentWindowId, session) {
|
||||
if (!extensionOptions.strictWindowIsolation) {
|
||||
sessionWindowMap.set(session, currentWindowId);
|
||||
return currentWindowId;
|
||||
}
|
||||
|
||||
let targetWindowId = sessionWindowMap.get(session);
|
||||
|
||||
if (typeof targetWindowId === 'number') {
|
||||
try {
|
||||
await chrome.windows.get(targetWindowId);
|
||||
} catch {
|
||||
sessionWindowMap.delete(session);
|
||||
targetWindowId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof targetWindowId !== 'number') {
|
||||
sessionWindowMap.set(session, currentWindowId);
|
||||
return currentWindowId;
|
||||
}
|
||||
|
||||
if (targetWindowId === currentWindowId) {
|
||||
return targetWindowId;
|
||||
}
|
||||
|
||||
await chrome.tabs.move(tabId, { windowId: targetWindowId, index: -1 });
|
||||
await chrome.tabs.update(tabId, { active: false }).catch(() => {});
|
||||
return targetWindowId;
|
||||
}
|
||||
|
||||
async function findExistingGroup(windowId, groupTitle) {
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const checked = new Set();
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (typeof tab.groupId !== 'number' || tab.groupId < 0 || checked.has(tab.groupId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
checked.add(tab.groupId);
|
||||
try {
|
||||
const group = await chrome.tabGroups.get(tab.groupId);
|
||||
if (group.title === groupTitle) {
|
||||
return tab.groupId;
|
||||
}
|
||||
} catch {
|
||||
// Ignore stale group references.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureSessionGroup(tabId, windowId, session, groupTitle) {
|
||||
const targetWindowId = await ensureSessionWindow(tabId, windowId, session);
|
||||
const key = cacheKey(targetWindowId, session);
|
||||
let groupId = sessionGroupCache.get(key);
|
||||
|
||||
if (typeof groupId === 'number') {
|
||||
try {
|
||||
await chrome.tabGroups.get(groupId);
|
||||
} catch {
|
||||
groupId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof groupId !== 'number') {
|
||||
const existing = await findExistingGroup(targetWindowId, groupTitle);
|
||||
if (typeof existing === 'number') {
|
||||
groupId = existing;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof groupId === 'number') {
|
||||
await chrome.tabs.group({ groupId, tabIds: [tabId] });
|
||||
} else {
|
||||
groupId = await chrome.tabs.group({
|
||||
tabIds: [tabId],
|
||||
createProperties: { windowId: targetWindowId },
|
||||
});
|
||||
}
|
||||
|
||||
const color = pickColorForSession(session);
|
||||
const collapsed = shouldCollapseGroup(session);
|
||||
await chrome.tabGroups.update(groupId, {
|
||||
title: groupTitle,
|
||||
color,
|
||||
collapsed,
|
||||
});
|
||||
|
||||
sessionGroupCache.set(key, groupId);
|
||||
sessionWindowMap.set(session, targetWindowId);
|
||||
|
||||
return {
|
||||
groupId,
|
||||
windowId: targetWindowId,
|
||||
color,
|
||||
collapsed,
|
||||
};
|
||||
}
|
||||
|
||||
async function applySessionDomainFallback(tabId, session) {
|
||||
const allowedDomains = getSessionPolicy(session);
|
||||
if (allowedDomains.length === 0) {
|
||||
return { enforced: false, blocked: false };
|
||||
}
|
||||
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch {
|
||||
return { enforced: true, blocked: false };
|
||||
}
|
||||
|
||||
const hostname = parseHostname(tab.url);
|
||||
if (!hostname) {
|
||||
return { enforced: true, blocked: false };
|
||||
}
|
||||
|
||||
if (isDomainAllowed(hostname, allowedDomains)) {
|
||||
return { enforced: true, blocked: false };
|
||||
}
|
||||
|
||||
await chrome.tabs.update(tabId, { url: 'about:blank' }).catch(() => {});
|
||||
return {
|
||||
enforced: true,
|
||||
blocked: true,
|
||||
reason: `${hostname} is not in allowed domains`,
|
||||
};
|
||||
}
|
||||
|
||||
function getManagedSessionForTab(tabId) {
|
||||
if (typeof tabId !== 'number') return undefined;
|
||||
return tabSessionMap.get(tabId);
|
||||
}
|
||||
|
||||
function collectSessionTabIds(session) {
|
||||
const result = [];
|
||||
for (const [tabId, tabSession] of tabSessionMap.entries()) {
|
||||
if (tabSession === session) {
|
||||
result.push(tabId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function closeOtherSessionTabs(session) {
|
||||
const normalized = normalizeSession(session);
|
||||
const closeIds = [];
|
||||
|
||||
for (const [tabId, tabSession] of tabSessionMap.entries()) {
|
||||
if (tabSession !== normalized) {
|
||||
closeIds.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
if (closeIds.length > 0) {
|
||||
await chrome.tabs.remove(closeIds);
|
||||
}
|
||||
|
||||
return { closed: closeIds.length };
|
||||
}
|
||||
|
||||
async function focusSession(session) {
|
||||
const normalized = normalizeSession(session);
|
||||
const tabIds = collectSessionTabIds(normalized);
|
||||
if (tabIds.length === 0) {
|
||||
return { focused: false };
|
||||
}
|
||||
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabIds[0]);
|
||||
} catch {
|
||||
return { focused: false };
|
||||
}
|
||||
|
||||
if (typeof tab.windowId === 'number') {
|
||||
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {});
|
||||
}
|
||||
await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
|
||||
return { focused: true, tabId: tab.id };
|
||||
}
|
||||
|
||||
async function cleanEmptyGroups() {
|
||||
let removedGroups = 0;
|
||||
let removedWindows = 0;
|
||||
|
||||
for (const [key, groupId] of [...sessionGroupCache.entries()]) {
|
||||
const [windowIdRaw] = key.split(':');
|
||||
const windowId = Number(windowIdRaw);
|
||||
|
||||
let groupExists = true;
|
||||
try {
|
||||
await chrome.tabGroups.get(groupId);
|
||||
} catch {
|
||||
groupExists = false;
|
||||
}
|
||||
|
||||
if (!groupExists) {
|
||||
sessionGroupCache.delete(key);
|
||||
removedGroups += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tabs = await chrome.tabs.query({ windowId }).catch(() => []);
|
||||
const hasMembers = tabs.some((tab) => tab.groupId === groupId);
|
||||
if (!hasMembers) {
|
||||
sessionGroupCache.delete(key);
|
||||
removedGroups += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [session, windowId] of [...sessionWindowMap.entries()]) {
|
||||
try {
|
||||
await chrome.windows.get(windowId);
|
||||
} catch {
|
||||
sessionWindowMap.delete(session);
|
||||
removedWindows += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { removedGroups, removedWindows };
|
||||
}
|
||||
|
||||
async function syncCleanupAlarm() {
|
||||
try {
|
||||
await chrome.alarms.clear(CLEANUP_ALARM_NAME);
|
||||
if (extensionOptions.autoCleanEmptyGroups) {
|
||||
await chrome.alarms.create(CLEANUP_ALARM_NAME, { periodInMinutes: 1 });
|
||||
}
|
||||
} catch {
|
||||
// Ignore alarms API failures.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceSessionWindowAffinity(tabId) {
|
||||
if (!extensionOptions.suppressCrossWindowActivation) return { moved: false };
|
||||
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (!session) return { moved: false };
|
||||
if (!extensionOptions.strictWindowIsolation) return { moved: false };
|
||||
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch {
|
||||
return { moved: false };
|
||||
}
|
||||
|
||||
const mappedWindowId = sessionWindowMap.get(session);
|
||||
if (typeof mappedWindowId !== 'number' || mappedWindowId === tab.windowId) {
|
||||
if (typeof tab.windowId === 'number') {
|
||||
sessionWindowMap.set(session, tab.windowId);
|
||||
}
|
||||
return { moved: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.tabs.move(tabId, { windowId: mappedWindowId, index: -1 });
|
||||
await chrome.tabs.update(tabId, { active: false }).catch(() => {});
|
||||
return { moved: true, toWindowId: mappedWindowId };
|
||||
} catch {
|
||||
return { moved: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRiskBadge(tabId) {
|
||||
let text = '';
|
||||
let title = 'agent-browser-stealth';
|
||||
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (session) {
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch {
|
||||
tab = undefined;
|
||||
}
|
||||
|
||||
if (tab) {
|
||||
const hints = collectRiskHints(tab.url, getSessionPolicy(session));
|
||||
if (hints.length > 0) {
|
||||
text = '!';
|
||||
title = `Risk hints (${hints.length}): ${hints.join(', ')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await chrome.action.setBadgeText({ text }).catch(() => {});
|
||||
await chrome.action.setBadgeBackgroundColor({ color: '#dc2626' }).catch(() => {});
|
||||
await chrome.action.setTitle({ title }).catch(() => {});
|
||||
}
|
||||
|
||||
async function buildPanelState() {
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
for (const tab of allTabs) {
|
||||
updateTabMeta(tab);
|
||||
}
|
||||
|
||||
const sessionMap = new Map();
|
||||
|
||||
for (const tab of allTabs) {
|
||||
if (typeof tab.id !== 'number') continue;
|
||||
const session = getManagedSessionForTab(tab.id);
|
||||
if (!session) continue;
|
||||
|
||||
if (!sessionMap.has(session)) {
|
||||
sessionMap.set(session, {
|
||||
session,
|
||||
windowId: sessionWindowMap.get(session) ?? tab.windowId,
|
||||
allowedDomains: getSessionPolicy(session),
|
||||
tabs: [],
|
||||
riskHints: [],
|
||||
});
|
||||
}
|
||||
|
||||
const entry = sessionMap.get(session);
|
||||
entry.tabs.push({
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
title: tab.title ?? '',
|
||||
url: tab.url ?? '',
|
||||
active: tab.active === true,
|
||||
groupId: typeof tab.groupId === 'number' ? tab.groupId : -1,
|
||||
});
|
||||
|
||||
const hints = collectRiskHints(tab.url, entry.allowedDomains);
|
||||
for (const hint of hints) {
|
||||
if (!entry.riskHints.includes(hint)) {
|
||||
entry.riskHints.push(hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = [];
|
||||
for (const sessionEntry of sessionMap.values()) {
|
||||
sessionEntry.tabs.sort((a, b) => Number(b.active) - Number(a.active));
|
||||
const key = cacheKey(sessionEntry.windowId, sessionEntry.session);
|
||||
const cachedGroupId = sessionGroupCache.get(key);
|
||||
|
||||
let group;
|
||||
if (typeof cachedGroupId === 'number') {
|
||||
try {
|
||||
const groupInfo = await chrome.tabGroups.get(cachedGroupId);
|
||||
group = {
|
||||
id: cachedGroupId,
|
||||
title: groupInfo.title,
|
||||
color: groupInfo.color,
|
||||
collapsed: groupInfo.collapsed,
|
||||
};
|
||||
} catch {
|
||||
// Group may no longer exist.
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
...sessionEntry,
|
||||
group,
|
||||
});
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => a.session.localeCompare(b.session));
|
||||
|
||||
return {
|
||||
extensionId: chrome.runtime.id,
|
||||
options: { ...extensionOptions },
|
||||
totals: {
|
||||
sessions: sessions.length,
|
||||
tabs: sessions.reduce((sum, session) => sum + session.tabs.length, 0),
|
||||
},
|
||||
sessions,
|
||||
downloads: downloadEvents.slice(-25).reverse(),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTabGroupRequest(message, sender) {
|
||||
await bootstrapPromise;
|
||||
|
||||
const tabId = sender.tab?.id;
|
||||
const windowId = sender.tab?.windowId;
|
||||
const nonce = typeof message.nonce === 'string' ? message.nonce : undefined;
|
||||
|
||||
if (typeof tabId !== 'number' || typeof windowId !== 'number') {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'missing-tab-context',
|
||||
extensionId: chrome.runtime.id,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof message.pluginId === 'string' && message.pluginId !== chrome.runtime.id) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'plugin-id-mismatch',
|
||||
extensionId: chrome.runtime.id,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
|
||||
const session = normalizeSession(message.session);
|
||||
const groupTitle = normalizeGroupTitle(message.groupTitle);
|
||||
const allowedDomains = normalizeAllowedDomains(message.allowedDomains);
|
||||
if (allowedDomains.length > 0) {
|
||||
await setSessionPolicy(session, allowedDomains);
|
||||
}
|
||||
|
||||
tabSessionMap.set(tabId, session);
|
||||
updateTabMeta(sender.tab);
|
||||
|
||||
const grouping = await ensureSessionGroup(tabId, windowId, session, groupTitle);
|
||||
const policy = await applySessionDomainFallback(tabId, session);
|
||||
const riskHints = collectRiskHints(sender.tab?.url, getSessionPolicy(session));
|
||||
if (policy.blocked && policy.reason) {
|
||||
riskHints.push(`policy-blocked:${policy.reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
extensionId: chrome.runtime.id,
|
||||
nonce,
|
||||
...grouping,
|
||||
policy,
|
||||
riskHints: [...new Set(riskHints)].slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});
|
||||
bootstrapPromise = bootstrapState();
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
bootstrapPromise = bootstrapState();
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = message.type;
|
||||
|
||||
if (type === REQUEST_TYPE) {
|
||||
handleTabGroupRequest(message, sender)
|
||||
.then((response) => sendResponse(response))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
extensionId: chrome.runtime.id,
|
||||
nonce: typeof message.nonce === 'string' ? message.nonce : undefined,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_GET_STATE) {
|
||||
bootstrapPromise
|
||||
.then(() => buildPanelState())
|
||||
.then((state) => sendResponse({ ok: true, state }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_CLOSE_OTHER_TABS) {
|
||||
closeOtherSessionTabs(message.session)
|
||||
.then((result) => sendResponse({ ok: true, result }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_FOCUS_SESSION) {
|
||||
focusSession(message.session)
|
||||
.then((result) => sendResponse({ ok: true, result }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_CLEAN_EMPTY_GROUPS) {
|
||||
cleanEmptyGroups()
|
||||
.then((result) => sendResponse({ ok: true, result }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_SET_POLICY) {
|
||||
bootstrapPromise
|
||||
.then(() => setSessionPolicy(message.session, message.allowedDomains))
|
||||
.then(() => sendResponse({ ok: true }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_SET_OPTIONS) {
|
||||
bootstrapPromise
|
||||
.then(() => setExtensionOptions(message.options))
|
||||
.then((options) => sendResponse({ ok: true, options }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
updateTabMeta(tab);
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (!session) {
|
||||
if (changeInfo.status === 'complete' && tab.active === true) {
|
||||
updateRiskBadge(tabId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof tab.windowId === 'number') {
|
||||
sessionWindowMap.set(session, tab.windowId);
|
||||
}
|
||||
|
||||
if (changeInfo.status === 'complete') {
|
||||
applySessionDomainFallback(tabId, session).catch(() => {});
|
||||
if (tab.active === true) {
|
||||
updateRiskBadge(tabId).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onActivated.addListener((activeInfo) => {
|
||||
enforceSessionWindowAffinity(activeInfo.tabId).catch(() => {});
|
||||
updateRiskBadge(activeInfo.tabId).catch(() => {});
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
tabSessionMap.delete(tabId);
|
||||
tabMetaById.delete(tabId);
|
||||
|
||||
if (removeInfo.isWindowClosing) {
|
||||
removeWindowCaches(removeInfo.windowId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session) return;
|
||||
const remaining = collectSessionTabIds(session);
|
||||
if (remaining.length === 0) {
|
||||
sessionWindowMap.delete(session);
|
||||
}
|
||||
cleanEmptyGroups().catch(() => {});
|
||||
});
|
||||
|
||||
chrome.tabs.onDetached.addListener((tabId) => {
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (!session) return;
|
||||
tabMetaById.delete(tabId);
|
||||
});
|
||||
|
||||
chrome.tabs.onAttached.addListener((tabId, attachInfo) => {
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (!session) return;
|
||||
sessionWindowMap.set(session, attachInfo.newWindowId);
|
||||
});
|
||||
|
||||
chrome.windows.onRemoved.addListener((windowId) => {
|
||||
removeWindowCaches(windowId);
|
||||
cleanEmptyGroups().catch(() => {});
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm?.name !== CLEANUP_ALARM_NAME) return;
|
||||
cleanEmptyGroups().catch(() => {});
|
||||
});
|
||||
|
||||
chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
|
||||
const session = getManagedSessionForTab(item.tabId);
|
||||
if (!session) {
|
||||
suggest();
|
||||
return;
|
||||
}
|
||||
|
||||
const safeSession = sanitizeSegment(session, 'default');
|
||||
const safeFilename = sanitizeFilename(item.filename, `download-${item.id}.bin`);
|
||||
const filename = `${DOWNLOAD_ARCHIVE_ROOT}/${safeSession}/${safeFilename}`;
|
||||
|
||||
recordDownloadEvent({
|
||||
id: item.id,
|
||||
tabId: item.tabId,
|
||||
session,
|
||||
state: 'routing',
|
||||
filename,
|
||||
});
|
||||
|
||||
suggest({
|
||||
filename,
|
||||
conflictAction: 'uniquify',
|
||||
});
|
||||
});
|
||||
|
||||
chrome.downloads.onChanged.addListener((delta) => {
|
||||
if (!delta || typeof delta.id !== 'number') return;
|
||||
|
||||
const state = delta.state?.current;
|
||||
if (!state) return;
|
||||
|
||||
recordDownloadEvent({
|
||||
id: delta.id,
|
||||
state,
|
||||
filename: delta.filename?.current,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--item-bg: rgba(255, 255, 255, 0.03);
|
||||
--item-hover: rgba(255, 255, 255, 0.06);
|
||||
--border: #30363d;
|
||||
--border-focus: #484f58;
|
||||
--text-main: #e6edf3;
|
||||
--text-muted: #848d97;
|
||||
--accent: #2f81f7;
|
||||
--accent-soft: rgba(47, 129, 247, 0.1);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-focus); }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-main);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--item-hover);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
#refresh-btn {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#refresh-btn:hover {
|
||||
background: #4493f8;
|
||||
border-color: #4493f8;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 12px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-title h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border: 1px solid transparent;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px; /* Divider effect */
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: var(--panel);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--item-hover);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.item-url {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
background: rgba(110, 118, 129, 0.4);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#summary code {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Summary Grid */
|
||||
#summary .tags {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#summary .tag {
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>agent-browser-stealth panel</title>
|
||||
<link rel="stylesheet" href="sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>agent-browser-stealth</h1>
|
||||
<div class="actions">
|
||||
<button id="refresh-btn" type="button">Refresh</button>
|
||||
<button id="cleanup-btn" type="button">Clean Empty Groups</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="summary" class="card"></section>
|
||||
<section id="sessions" class="stack"></section>
|
||||
<section id="downloads" class="card"></section>
|
||||
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,246 @@
|
||||
const summaryEl = document.getElementById('summary');
|
||||
const sessionsEl = document.getElementById('sessions');
|
||||
const downloadsEl = document.getElementById('downloads');
|
||||
const refreshBtn = document.getElementById('refresh-btn');
|
||||
const cleanupBtn = document.getElementById('cleanup-btn');
|
||||
|
||||
async function send(message) {
|
||||
return chrome.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
function createTag(text) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tag';
|
||||
span.textContent = text;
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderSummary(state) {
|
||||
summaryEl.innerHTML = '<h3>Overview</h3>';
|
||||
const options = state.options || {};
|
||||
|
||||
const idInfo = document.createElement('div');
|
||||
idInfo.style.marginBottom = '12px';
|
||||
idInfo.style.fontSize = '11px';
|
||||
idInfo.style.color = 'var(--text-muted)';
|
||||
idInfo.innerHTML = `Extension ID: <code>${state.extensionId}</code>`;
|
||||
summaryEl.appendChild(idInfo);
|
||||
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'tags';
|
||||
tags.appendChild(createTag(`Sessions: ${state.totals.sessions}`));
|
||||
tags.appendChild(createTag(`Tabs: ${state.totals.tabs}`));
|
||||
tags.appendChild(
|
||||
createTag(`Isolation: ${options.strictWindowIsolation === false ? 'Off' : 'On'}`)
|
||||
);
|
||||
tags.appendChild(
|
||||
createTag(`Guard: ${options.suppressCrossWindowActivation === false ? 'Off' : 'On'}`)
|
||||
);
|
||||
tags.appendChild(
|
||||
createTag(`Auto-Clean: ${options.autoCleanEmptyGroups === false ? 'Off' : 'On'}`)
|
||||
);
|
||||
|
||||
const optionActions = document.createElement('div');
|
||||
optionActions.className = 'row-actions';
|
||||
|
||||
const createOptionBtn = (text, active, onClick) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = text;
|
||||
if (active) btn.style.borderColor = 'var(--accent)';
|
||||
btn.addEventListener('click', onClick);
|
||||
return btn;
|
||||
};
|
||||
|
||||
optionActions.appendChild(
|
||||
createOptionBtn('Strict Isolation', options.strictWindowIsolation !== false, async () => {
|
||||
await send({
|
||||
type: 'AB_PANEL_SET_OPTIONS',
|
||||
options: { ...options, strictWindowIsolation: options.strictWindowIsolation === false },
|
||||
});
|
||||
await refresh();
|
||||
})
|
||||
);
|
||||
|
||||
optionActions.appendChild(
|
||||
createOptionBtn('Activation Guard', options.suppressCrossWindowActivation !== false, async () => {
|
||||
await send({
|
||||
type: 'AB_PANEL_SET_OPTIONS',
|
||||
options: {
|
||||
...options,
|
||||
suppressCrossWindowActivation: options.suppressCrossWindowActivation === false,
|
||||
},
|
||||
});
|
||||
await refresh();
|
||||
})
|
||||
);
|
||||
|
||||
optionActions.appendChild(
|
||||
createOptionBtn('Auto-Clean', options.autoCleanEmptyGroups !== false, async () => {
|
||||
await send({
|
||||
type: 'AB_PANEL_SET_OPTIONS',
|
||||
options: {
|
||||
...options,
|
||||
autoCleanEmptyGroups: options.autoCleanEmptyGroups === false,
|
||||
},
|
||||
});
|
||||
await refresh();
|
||||
})
|
||||
);
|
||||
|
||||
summaryEl.appendChild(tags);
|
||||
summaryEl.appendChild(optionActions);
|
||||
}
|
||||
|
||||
function renderSessions(state) {
|
||||
sessionsEl.innerHTML = '<h3>Active Sessions</h3>';
|
||||
|
||||
if (!state.sessions || state.sessions.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card empty';
|
||||
empty.textContent = 'No active sessions monitored.';
|
||||
sessionsEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const session of state.sessions) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card';
|
||||
|
||||
const titleRow = document.createElement('div');
|
||||
titleRow.className = 'session-title';
|
||||
|
||||
const titleLeft = document.createElement('h4');
|
||||
titleLeft.textContent = session.session;
|
||||
|
||||
const focusBtn = document.createElement('button');
|
||||
focusBtn.textContent = 'Focus';
|
||||
focusBtn.addEventListener('click', async () => {
|
||||
await send({ type: 'AB_PANEL_FOCUS_SESSION', session: session.session });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
titleRow.appendChild(titleLeft);
|
||||
titleRow.appendChild(focusBtn);
|
||||
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'tags';
|
||||
tags.appendChild(createTag(`Window: ${session.windowId ?? 'N/A'}`));
|
||||
tags.appendChild(createTag(`Tabs: ${session.tabs.length}`));
|
||||
|
||||
if (session.group) {
|
||||
tags.appendChild(createTag(`G: ${session.group.title || 'Untitled'}`));
|
||||
}
|
||||
|
||||
if (session.allowedDomains && session.allowedDomains.length > 0) {
|
||||
tags.appendChild(createTag(`Allowlist: ${session.allowedDomains.length} domains`));
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'list';
|
||||
for (const tab of session.tabs.slice(0, 10)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item';
|
||||
|
||||
const t = document.createElement('div');
|
||||
t.className = 'item-title';
|
||||
if (tab.active) {
|
||||
const dot = document.createElement('span');
|
||||
dot.textContent = '●';
|
||||
dot.style.color = 'var(--success)';
|
||||
dot.style.marginRight = '6px';
|
||||
dot.style.fontSize = '10px';
|
||||
t.appendChild(dot);
|
||||
}
|
||||
t.appendChild(document.createTextNode(tab.title || '(Untitled)'));
|
||||
|
||||
const u = document.createElement('div');
|
||||
u.className = 'item-url';
|
||||
u.textContent = tab.url || 'about:blank';
|
||||
|
||||
item.appendChild(t);
|
||||
item.appendChild(u);
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
const footerActions = document.createElement('div');
|
||||
footerActions.className = 'row-actions';
|
||||
|
||||
const keepBtn = document.createElement('button');
|
||||
keepBtn.textContent = 'Isolate Session';
|
||||
keepBtn.addEventListener('click', async () => {
|
||||
await send({ type: 'AB_PANEL_CLOSE_OTHER_SESSION_TABS', session: session.session });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
const policyBtn = document.createElement('button');
|
||||
policyBtn.textContent = 'Config Policy';
|
||||
policyBtn.addEventListener('click', async () => {
|
||||
const current = (session.allowedDomains || []).join(',');
|
||||
const input = window.prompt('Allowed domains (comma-separated)', current);
|
||||
if (input === null) return;
|
||||
const allowedDomains = input
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item) => item.length > 0);
|
||||
await send({ type: 'AB_PANEL_SET_POLICY', session: session.session, allowedDomains });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
footerActions.appendChild(keepBtn);
|
||||
footerActions.appendChild(policyBtn);
|
||||
|
||||
card.appendChild(titleRow);
|
||||
card.appendChild(tags);
|
||||
card.appendChild(list);
|
||||
card.appendChild(footerActions);
|
||||
sessionsEl.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDownloads(state) {
|
||||
downloadsEl.innerHTML = '<h3>Recent Downloads</h3>';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'list';
|
||||
|
||||
const entries = state.downloads || [];
|
||||
if (entries.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = 'No download events yet.';
|
||||
list.appendChild(empty);
|
||||
} else {
|
||||
for (const entry of entries.slice(0, 8)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item';
|
||||
item.innerHTML = `<div class="item-title">#${entry.id} · ${entry.state || 'updated'}</div><div class="item-url">${entry.filename || ''}</div>`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
downloadsEl.appendChild(list);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const response = await send({ type: 'AB_PANEL_GET_STATE' });
|
||||
if (!response || response.ok !== true || !response.state) {
|
||||
summaryEl.textContent = response?.error || 'Failed to load extension state.';
|
||||
sessionsEl.innerHTML = '';
|
||||
downloadsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
renderSummary(response.state);
|
||||
renderSessions(response.state);
|
||||
renderDownloads(response.state);
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener('click', refresh);
|
||||
cleanupBtn.addEventListener('click', async () => {
|
||||
await send({ type: 'AB_PANEL_CLEAN_EMPTY_GROUPS' });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
+12
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.15.2-fork.0",
|
||||
"version": "0.16.1-fork.3",
|
||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
@@ -12,20 +12,22 @@
|
||||
],
|
||||
"bin": {
|
||||
"agent-browser-stealth": "./bin/agent-browser.js",
|
||||
"agent-browser": "./bin/agent-browser.js"
|
||||
"agent-browser": "./bin/agent-browser.js",
|
||||
"abs": "./bin/agent-browser.js"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||
"native:clean": "cargo clean --manifest-path cli/Cargo.toml -p agent-browser-stealth",
|
||||
"build": "tsc",
|
||||
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:native": "npm run version:sync && npm run native:clean && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:macos": "npm run version:sync && npm run native:clean && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish",
|
||||
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm run verify:bundled-binaries && npm run verify:native-version && npm publish",
|
||||
"start": "node dist/daemon.js",
|
||||
"dev": "tsx src/daemon.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -34,14 +36,18 @@
|
||||
"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",
|
||||
"check:stealth-regression": "node scripts/check-stealth-regression.js",
|
||||
"check:turnstile-testkey": "pnpm exec tsx scripts/check-turnstile-testkey.ts",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"verify:native-version": "node scripts/verify-native-version.js",
|
||||
"verify:bundled-binaries": "node scripts/verify-bundled-binaries.js",
|
||||
"clawhub:sync": "bash scripts/clawhub-sync.sh",
|
||||
"sync:upstream": "bash scripts/sync-upstream.sh",
|
||||
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
|
||||
"changeset": "changeset",
|
||||
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
|
||||
"ci:publish": "pnpm run version:sync && pnpm run build && pnpm run build:native && pnpm run verify:native-version && changeset publish"
|
||||
"ci:publish": "pnpm run version:sync && pnpm run build && pnpm run build:native && pnpm run verify:bundled-binaries && pnpm run verify:native-version && changeset publish"
|
||||
},
|
||||
"keywords": [
|
||||
"browser",
|
||||
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Regression check for daemon liveness when <session>.pid is missing.
|
||||
*
|
||||
* What it verifies:
|
||||
* 1) A daemon session is reachable.
|
||||
* 2) Deleting <session>.pid does not break the next command.
|
||||
* 3) The session socket is not recreated (inode unchanged on Unix),
|
||||
* meaning we reused the live daemon instead of tearing it down.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-daemon-pid-recovery.js
|
||||
* node scripts/check-daemon-pid-recovery.js --session default
|
||||
* node scripts/check-daemon-pid-recovery.js --binary ./bin/agent-browser-darwin-arm64
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path, { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getArgValue = (name, fallback) => {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1 || index + 1 >= args.length) return fallback;
|
||||
return args[index + 1];
|
||||
};
|
||||
|
||||
function resolveSocketDir() {
|
||||
if (process.env.AGENT_BROWSER_SOCKET_DIR && process.env.AGENT_BROWSER_SOCKET_DIR.length > 0) {
|
||||
return process.env.AGENT_BROWSER_SOCKET_DIR;
|
||||
}
|
||||
if (process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0) {
|
||||
return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser');
|
||||
}
|
||||
return path.join(os.homedir(), '.agent-browser');
|
||||
}
|
||||
|
||||
function resolveDefaultBinary() {
|
||||
const osKey = os.platform() === 'win32' ? 'win32' : os.platform() === 'darwin' ? 'darwin' : 'linux';
|
||||
const archKey = os.arch() === 'arm64' ? 'arm64' : 'x64';
|
||||
const ext = os.platform() === 'win32' ? '.exe' : '';
|
||||
|
||||
const candidates = [
|
||||
join(rootDir, 'bin', `agent-browser-${osKey}-${archKey}${ext}`),
|
||||
join(rootDir, 'cli', 'target', 'release', `agent-browser${ext}`),
|
||||
join(rootDir, 'bin', `agent-browser-local${ext}`),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function runCommand(binary, commandArgs, allowFailure = false) {
|
||||
const result = spawnSync(binary, commandArgs, {
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
const stderr = (result.stderr || '').trim();
|
||||
const stdout = (result.stdout || '').trim();
|
||||
throw new Error(
|
||||
`Command failed: ${binary} ${commandArgs.join(' ')}\n${stderr || stdout || `exit code ${result.status}`}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const session = getArgValue('--session', 'default');
|
||||
const binary = getArgValue('--binary', resolveDefaultBinary());
|
||||
const socketDir = resolveSocketDir();
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
const pidPath = join(socketDir, `${session}.pid`);
|
||||
const socketPath = isWindows ? null : join(socketDir, `${session}.sock`);
|
||||
const portPath = isWindows ? join(socketDir, `${session}.port`) : null;
|
||||
|
||||
if (!fs.existsSync(binary)) {
|
||||
throw new Error(`Binary not found: ${binary}`);
|
||||
}
|
||||
|
||||
// Ensure daemon/session is live before we simulate pid loss.
|
||||
runCommand(binary, ['--session', session, 'get', 'url']);
|
||||
|
||||
let socketInodeBefore = null;
|
||||
if (!isWindows) {
|
||||
if (!socketPath || !fs.existsSync(socketPath)) {
|
||||
throw new Error(`Socket file not found: ${socketPath}`);
|
||||
}
|
||||
socketInodeBefore = fs.statSync(socketPath).ino;
|
||||
}
|
||||
|
||||
const pidExistedBefore = fs.existsSync(pidPath);
|
||||
if (pidExistedBefore) {
|
||||
fs.unlinkSync(pidPath);
|
||||
}
|
||||
|
||||
// This is the critical step: should still work even though pid file is gone.
|
||||
const second = runCommand(binary, ['--session', session, 'get', 'title']);
|
||||
const secondOutput = (second.stdout || '').trim();
|
||||
|
||||
let socketInodeAfter = null;
|
||||
let socketUnchanged = true;
|
||||
if (!isWindows) {
|
||||
if (!socketPath || !fs.existsSync(socketPath)) {
|
||||
throw new Error(`Socket file missing after pid removal: ${socketPath}`);
|
||||
}
|
||||
socketInodeAfter = fs.statSync(socketPath).ino;
|
||||
socketUnchanged = socketInodeBefore === socketInodeAfter;
|
||||
} else if (portPath && !fs.existsSync(portPath)) {
|
||||
throw new Error(`Port file missing after pid removal: ${portPath}`);
|
||||
}
|
||||
|
||||
const pidExistsAfter = fs.existsSync(pidPath);
|
||||
const passed = socketUnchanged;
|
||||
|
||||
const report = {
|
||||
passed,
|
||||
session,
|
||||
binary,
|
||||
socketDir,
|
||||
pidPath,
|
||||
pidExistedBefore,
|
||||
pidExistsAfter,
|
||||
socketPath,
|
||||
socketInodeBefore,
|
||||
socketInodeAfter,
|
||||
socketUnchanged,
|
||||
secondCommandOutput: secondOutput,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!passed) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* End-to-end stealth regression check across key anti-bot targets.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-stealth-regression.js
|
||||
* node scripts/check-stealth-regression.js --binary ./cli/target/release/agent-browser
|
||||
* node scripts/check-stealth-regression.js --session-name stealth-regression
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getArgValue = (name, fallback) => {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1 || index + 1 >= args.length) return fallback;
|
||||
return args[index + 1];
|
||||
};
|
||||
|
||||
const sessionName = getArgValue('--session-name', 'stealth-regression');
|
||||
const screenshotDir = getArgValue('--screenshot-dir', join('/tmp', 'agent-browser-stealth-regression'));
|
||||
const binaryArg = getArgValue('--binary', '');
|
||||
|
||||
const candidates = [
|
||||
binaryArg,
|
||||
join(rootDir, 'cli', 'target', 'release', 'agent-browser'),
|
||||
join(rootDir, 'bin', 'agent-browser.js'),
|
||||
'agent-browser-stealth',
|
||||
'agent-browser',
|
||||
].filter(Boolean);
|
||||
|
||||
function tryResolveBinary() {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.includes('/') && !existsSync(candidate)) continue;
|
||||
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
|
||||
if (probe.status === 0) return candidate;
|
||||
}
|
||||
throw new Error(
|
||||
`Unable to find a runnable agent-browser binary. Tried: ${candidates.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const binary = tryResolveBinary();
|
||||
const sessionArgs = ['--session', sessionName, '--session-name', sessionName];
|
||||
|
||||
function runBinary(commandArgs, { allowFailure = false } = {}) {
|
||||
const result = spawnSync(binary, commandArgs, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
const stderr = (result.stderr || '').trim();
|
||||
const stdout = (result.stdout || '').trim();
|
||||
throw new Error(
|
||||
`Command failed: ${binary} ${commandArgs.join(' ')}\n` +
|
||||
`${stderr || stdout || `exit code ${result.status}`}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runJson(actionArgs, options = {}) {
|
||||
const result = runBinary([...sessionArgs, '--json', ...actionArgs], options);
|
||||
const output = (result.stdout || '').trim();
|
||||
if (!output) return null;
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
throw new Error(`Expected JSON output, got:\n${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
const genericRiskScript = `(() => {
|
||||
const lowerTitle = String(document.title || '').toLowerCase();
|
||||
const lowerBody = String(document.body?.innerText || '').toLowerCase();
|
||||
const hasCloudflare =
|
||||
lowerTitle.includes('just a moment') ||
|
||||
lowerTitle.includes('performing security verification') ||
|
||||
lowerBody.includes('performing security verification') ||
|
||||
lowerBody.includes('checking your browser') ||
|
||||
lowerBody.includes('cloudflare');
|
||||
const hasCaptcha =
|
||||
lowerBody.includes('captcha') ||
|
||||
lowerBody.includes('recaptcha') ||
|
||||
lowerBody.includes('hcaptcha') ||
|
||||
lowerBody.includes('turnstile');
|
||||
return {
|
||||
title: document.title || '',
|
||||
url: location.href,
|
||||
hasCloudflare,
|
||||
hasCaptcha,
|
||||
hasTurnstile:
|
||||
!!document.querySelector('.cf-turnstile, iframe[src*="challenges.cloudflare.com"], [name="cf-turnstile-response"]'),
|
||||
bodySample: lowerBody.slice(0, 600),
|
||||
};
|
||||
})()`;
|
||||
|
||||
const sannysoftScript = `(() => {
|
||||
const normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
|
||||
const rows = Array.from(document.querySelectorAll('tr'));
|
||||
const failed = rows.filter((row) => /failed|fail/i.test(normalize(row.innerText)));
|
||||
return {
|
||||
failedCount: failed.length,
|
||||
failedRows: failed.map((row) => normalize(row.innerText)),
|
||||
navigatorWebdriver: navigator.webdriver,
|
||||
navigatorVendor: navigator.vendor,
|
||||
};
|
||||
})()`;
|
||||
|
||||
function sanitizeFileSegment(input) {
|
||||
return input.replace(/[^a-zA-Z0-9._-]+/g, '-');
|
||||
}
|
||||
|
||||
function main() {
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
const timestamp = new Date().toISOString();
|
||||
const targets = [
|
||||
'https://bot.sannysoft.com/',
|
||||
'https://chatgpt.com/',
|
||||
'https://super86.cc/login',
|
||||
];
|
||||
|
||||
runBinary([...sessionArgs, 'close'], { allowFailure: true });
|
||||
|
||||
const report = {
|
||||
binary,
|
||||
sessionName,
|
||||
timestamp,
|
||||
screenshotDir,
|
||||
doctor: null,
|
||||
targets: [],
|
||||
ok: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const doctorResp = runJson(['doctor']);
|
||||
report.doctor = doctorResp?.data ?? null;
|
||||
|
||||
for (const target of targets) {
|
||||
const entry = {
|
||||
target,
|
||||
open: null,
|
||||
risk: null,
|
||||
sannysoft: null,
|
||||
screenshot: null,
|
||||
ok: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const openResp = runJson(['open', target]);
|
||||
entry.open = openResp?.data ?? null;
|
||||
|
||||
runJson(['wait', '3000'], { allowFailure: true });
|
||||
|
||||
const riskResp = runJson(['eval', genericRiskScript]);
|
||||
entry.risk = riskResp?.data?.result ?? null;
|
||||
|
||||
if (target.includes('bot.sannysoft.com')) {
|
||||
const sannysoftResp = runJson(['eval', sannysoftScript]);
|
||||
entry.sannysoft = sannysoftResp?.data?.result ?? null;
|
||||
if ((entry.sannysoft?.failedCount ?? 1) > 0) {
|
||||
entry.ok = false;
|
||||
}
|
||||
} else if (entry.risk?.hasCloudflare || entry.risk?.hasCaptcha) {
|
||||
entry.ok = false;
|
||||
}
|
||||
|
||||
const host = sanitizeFileSegment(new URL(target).host);
|
||||
const shotPath = join(
|
||||
screenshotDir,
|
||||
`${host}-${Date.now().toString(36)}.png`
|
||||
);
|
||||
runJson(['screenshot', '--full', shotPath], { allowFailure: true });
|
||||
entry.screenshot = shotPath;
|
||||
} catch (error) {
|
||||
entry.ok = false;
|
||||
entry.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
if (!entry.ok) report.ok = false;
|
||||
report.targets.push(entry);
|
||||
}
|
||||
} finally {
|
||||
runBinary([...sessionArgs, 'close'], { allowFailure: true });
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Deterministic Turnstile check using Cloudflare official testing sitekey.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm run check:turnstile-testkey
|
||||
* pnpm run check:turnstile-testkey -- --headed
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { BrowserManager } from '../src/browser.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const headed = args.includes('--headed');
|
||||
const waitMsRaw = args.includes('--wait-ms')
|
||||
? args[args.indexOf('--wait-ms') + 1]
|
||||
: undefined;
|
||||
const waitMs = Number.isFinite(Number(waitMsRaw)) ? Number(waitMsRaw) : 9000;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>agent-browser turnstile testkey</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Turnstile Testkey Probe</h1>
|
||||
<div class="cf-turnstile" data-sitekey="1x00000000000000000000AA" data-callback="onTurnstileToken"></div>
|
||||
<script>
|
||||
window.__turnstileToken = '';
|
||||
function onTurnstileToken(token) {
|
||||
window.__turnstileToken = token || '';
|
||||
}
|
||||
</script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function createServer(): Promise<http.Server> {
|
||||
const server = http.createServer((_, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(html);
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const server = await createServer();
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
await closeServer(server);
|
||||
throw new Error('Unable to start local HTTP server for Turnstile probe');
|
||||
}
|
||||
|
||||
const localUrl = `http://127.0.0.1:${address.port}/`;
|
||||
const browser = new BrowserManager();
|
||||
|
||||
try {
|
||||
await browser.launch({
|
||||
id: 'turnstile-testkey',
|
||||
action: 'launch',
|
||||
browser: 'chromium',
|
||||
stealth: true,
|
||||
headless: !headed,
|
||||
});
|
||||
|
||||
const page = browser.getPage();
|
||||
await page.goto(localUrl, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const hidden = document.querySelector('input[name="cf-turnstile-response"]') as
|
||||
| HTMLInputElement
|
||||
| null;
|
||||
const hiddenValue = hidden?.value || '';
|
||||
const callbackValue =
|
||||
typeof (window as any).__turnstileToken === 'string'
|
||||
? (window as any).__turnstileToken
|
||||
: '';
|
||||
const token = hiddenValue || callbackValue || '';
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title || '',
|
||||
tokenLength: token.length,
|
||||
tokenSample: token.slice(0, 40),
|
||||
isDummyToken: token.includes('DUMMY'),
|
||||
hiddenFieldLength: hiddenValue.length,
|
||||
callbackLength: callbackValue.length,
|
||||
widgetCount: document.querySelectorAll('.cf-turnstile').length,
|
||||
};
|
||||
});
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
headed,
|
||||
waitMs,
|
||||
ok: result.isDummyToken,
|
||||
result,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(result.isDummyToken ? 0 : 1);
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
await closeServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
+21
-2
@@ -161,7 +161,19 @@ function showPlaywrightReminder() {
|
||||
* Fix npm's bin entry on global installs to use the native binary directly.
|
||||
* This provides zero-overhead CLI execution for global installs.
|
||||
*/
|
||||
function isPnpmGlobalInstall() {
|
||||
const ua = process.env.npm_config_user_agent || '';
|
||||
return ua.includes('pnpm/');
|
||||
}
|
||||
|
||||
async function fixGlobalInstallBin() {
|
||||
// pnpm already manages global shims in its own bin dir.
|
||||
// Rewriting links via `npm prefix -g` can create stale links in unrelated paths
|
||||
// (e.g. /opt/homebrew/bin), which then shadow pnpm's up-to-date shims.
|
||||
if (isPnpmGlobalInstall()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform() === 'win32') {
|
||||
await fixWindowsShims();
|
||||
} else {
|
||||
@@ -220,17 +232,24 @@ async function fixUnixSymlink() {
|
||||
* We overwrite them to invoke the native .exe directly.
|
||||
*/
|
||||
async function fixWindowsShims() {
|
||||
// Check if this is a global install by looking for npm's global prefix
|
||||
let npmBinDir;
|
||||
try {
|
||||
npmBinDir = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return; // Not a global install or npm not available
|
||||
return;
|
||||
}
|
||||
|
||||
// Path to native binary relative to npm prefix
|
||||
const packagePath = packageName.replace(/\//g, '\\');
|
||||
const relativeBinaryPath = `node_modules\\${packagePath}\\bin\\${binaryName}`;
|
||||
const absoluteBinaryPath = join(npmBinDir, relativeBinaryPath);
|
||||
|
||||
// npm may create shims after lifecycle scripts, and binary may be absent
|
||||
// when running with JS fallback; skip rewriting in those cases.
|
||||
if (!existsSync(absoluteBinaryPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let optimized = false;
|
||||
|
||||
for (const commandName of binCommands) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Verifies that all bundled platform binaries are present and embed the
|
||||
* package.json version string. This catches stale binary bundles where the
|
||||
* package version is bumped but one or more binaries were not rebuilt.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8"));
|
||||
const expectedVersion = String(pkg.version || "").trim();
|
||||
|
||||
if (!expectedVersion) {
|
||||
console.error("Error: package.json version is empty");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const expectedBinaries = [
|
||||
"agent-browser-linux-x64",
|
||||
"agent-browser-linux-arm64",
|
||||
"agent-browser-win32-x64.exe",
|
||||
"agent-browser-darwin-x64",
|
||||
"agent-browser-darwin-arm64",
|
||||
];
|
||||
|
||||
const minSizeBytes = 100_000;
|
||||
const versionBytes = Buffer.from(expectedVersion, "utf8");
|
||||
|
||||
let errors = 0;
|
||||
|
||||
for (const name of expectedBinaries) {
|
||||
const binaryPath = join(rootDir, "bin", name);
|
||||
if (!existsSync(binaryPath)) {
|
||||
console.error(`ERROR: missing binary: bin/${name}`);
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const size = statSync(binaryPath).size;
|
||||
if (size < minSizeBytes) {
|
||||
console.error(
|
||||
`ERROR: binary too small: bin/${name} (${size} bytes, expected >= ${minSizeBytes})`
|
||||
);
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = readFileSync(binaryPath);
|
||||
if (!bytes.includes(versionBytes)) {
|
||||
console.error(
|
||||
`ERROR: stale binary version: bin/${name} does not contain "${expectedVersion}"`
|
||||
);
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`OK: bin/${name} matches version ${expectedVersion}`);
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
console.error(`\nFound ${errors} binary validation issue(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nAll bundled binaries match package.json version ${expectedVersion}.`);
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: agent-browser
|
||||
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
|
||||
allowed-tools: Bash(npx agent-browser-stealth:*), Bash(npx agent-browser:*), Bash(agent-browser:*)
|
||||
allowed-tools: Bash(npx agent-browser-stealth:*), Bash(npx agent-browser:*), Bash(agent-browser:*), Bash(abs:*)
|
||||
---
|
||||
|
||||
# Browser Automation with agent-browser
|
||||
|
||||
Install package: `pnpm add -g agent-browser-stealth` (CLI command remains `agent-browser` for compatibility). If global install is unavailable in your environment, use `pnpm dlx agent-browser-stealth <command>` for one-off runs.
|
||||
Install package: `pnpm add -g agent-browser-stealth` (CLI commands: `agent-browser`, `agent-browser-stealth`, and short alias `abs`). If global install is unavailable in your environment, use `pnpm dlx agent-browser-stealth <command>` for one-off runs.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
@@ -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 + sourceURL + tab-group plugin health
|
||||
agent-browser close # Close browser
|
||||
agent-browser --version # Show CLI version (fork builds include upstream/fork)
|
||||
|
||||
@@ -89,6 +90,7 @@ agent-browser wait 2000-5000 # Random wait between 2-5 seconds
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
agent-browser --download-path ./downloads open <url> # Set default download directory
|
||||
agent-browser --tab-group "My Agent Group" open <url> # Override default tab-group base title
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
@@ -171,6 +173,7 @@ agent-browser cookies set callback_token "token123"
|
||||
|
||||
```bash
|
||||
# Auto-save/restore cookies and localStorage across browser restarts
|
||||
# If --session-name is omitted, it defaults to --session (or "default")
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
# ... login flow ...
|
||||
agent-browser close # State auto-saved to ~/.agent-browser/sessions/
|
||||
@@ -232,6 +235,15 @@ agent-browser --cdp 9222 snapshot
|
||||
|
||||
# Debug auto-attach behavior
|
||||
agent-browser --debug snapshot
|
||||
|
||||
# Diagnose CDP + sourceURL + plugin handshake status
|
||||
agent-browser doctor
|
||||
|
||||
# Avoid `open` timeout on challenge-heavy pages
|
||||
agent-browser --wait-until domcontentloaded open https://example.com
|
||||
|
||||
# Deterministic Turnstile smoke check (official test key)
|
||||
pnpm run check:turnstile-testkey
|
||||
```
|
||||
|
||||
### Color Scheme (Dark Mode)
|
||||
@@ -247,6 +259,37 @@ AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com
|
||||
agent-browser set media dark
|
||||
```
|
||||
|
||||
### Tab Grouping
|
||||
|
||||
```bash
|
||||
# CDP mode groups tabs when tab-group extension is installed
|
||||
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
|
||||
|
||||
# Override expected extension ID if needed
|
||||
AGENT_BROWSER_TAB_GROUP_PLUGIN_ID="<extension-id>" agent-browser open https://example.com
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Works in CDP mode via extension handshake.
|
||||
- Extension package name in Chrome: `agent-browser-stealth`.
|
||||
- Extension installed and reachable: tabs are grouped by session.
|
||||
- Extension missing/unavailable: silent no-op (no warning/error unless debug mode).
|
||||
- Default titles:
|
||||
- `default` session: `Agent Browser Stealth`
|
||||
- non-default session: `Agent Browser Stealth • <session>`
|
||||
- Additional extension-side capabilities:
|
||||
- Session window isolation + deterministic group colors.
|
||||
- Side panel controls: Focus / Keep Only This / Clean Empty Groups + isolation/auto-clean toggles.
|
||||
- Session allowlist policy editing and fallback blocking (`about:blank`).
|
||||
- Download auto-routing to `agent-browser-stealth/<session>/...`.
|
||||
|
||||
### Visual Browser (Debugging)
|
||||
|
||||
```bash
|
||||
@@ -278,7 +321,7 @@ Stealth is always active -- no flags needed. All sessions automatically apply an
|
||||
|
||||
Chromium launches in managed mode use Chrome channel by default for a genuine browser binary fingerprint.
|
||||
|
||||
For best results against strong bot detection, use `--headed` and `--session-name`.
|
||||
For best results against strong bot detection, use `--headed` and keep one stable `--session-name`.
|
||||
|
||||
### Auto Region Detection
|
||||
|
||||
@@ -290,7 +333,7 @@ Override: `AGENT_BROWSER_LOCALE`, `AGENT_BROWSER_TIMEZONE` env vars.
|
||||
|
||||
When a navigation lands on a captcha/verification page, behavior is controlled by `--risk-mode` (or `AGENT_BROWSER_RISK_MODE`):
|
||||
|
||||
- `warn` (default): retry up to 2 times with randomized backoff (3-7s), then return warning plus structured `riskSignals`
|
||||
- `warn` (default): wait for auto-clear first, then retry up to 2 times with randomized backoff (3-7s), then return warning plus structured `riskSignals`
|
||||
- `block`: fail fast once a risk interstitial is detected
|
||||
- `off`: disable this detection/retry path
|
||||
|
||||
@@ -439,7 +482,7 @@ agent-browser automatically humanizes interactions to avoid behavioral detection
|
||||
- **Random wait ranges**: `wait 2000-5000` pauses for a random duration in that range
|
||||
- **Bezier curve mouse**: Before every `click`, the mouse moves along a natural-looking curve
|
||||
|
||||
These behaviors are always active. For sensitive sites, combine with `--headed` and `--session-name` for best results.
|
||||
These behaviors are always active. For sensitive sites, combine with `--headed` and a stable `--session-name` for best results.
|
||||
|
||||
## Session Management and Cleanup
|
||||
|
||||
@@ -564,6 +607,21 @@ Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.
|
||||
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
|
||||
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
|
||||
|
||||
## Experimental: Native Mode
|
||||
|
||||
agent-browser has an experimental native Rust daemon that communicates with Chrome directly via CDP, bypassing Node.js and Playwright entirely. It is opt-in and not recommended for production use yet.
|
||||
|
||||
```bash
|
||||
# Enable via flag
|
||||
agent-browser --native open example.com
|
||||
|
||||
# Enable via environment variable (avoids passing --native every time)
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session.
|
||||
|
||||
## Ready-to-Use Templates
|
||||
|
||||
| Template | Description |
|
||||
|
||||
+127
-2
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectRiskSignals, toAIFriendlyError } from './actions.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { detectRiskSignals, executeCommand, toAIFriendlyError } from './actions.js';
|
||||
|
||||
describe('toAIFriendlyError', () => {
|
||||
describe('element blocked by overlay', () => {
|
||||
@@ -55,4 +55,129 @@ describe('detectRiskSignals', () => {
|
||||
const signals = detectRiskSignals('https://example.com/dashboard', 'Dashboard');
|
||||
expect(signals).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect cloudflare security verification text', () => {
|
||||
const signals = detectRiskSignals(
|
||||
'https://dash.cloudflare.com/zone/abc/ssl-tls/acm',
|
||||
'dash.cloudflare.com',
|
||||
'Performing security verification Verifying... This website uses a security service to protect against malicious bots.'
|
||||
);
|
||||
expect(signals.some((s) => s.code === 'verification_interstitial')).toBe(true);
|
||||
expect(signals.some((s) => s.code === 'bot_challenge')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping fallback', () => {
|
||||
it('should keep navigate successful when tab grouping trigger throws', async () => {
|
||||
const page = {
|
||||
waitForTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
url: vi.fn().mockReturnValue('https://example.com/'),
|
||||
title: vi.fn().mockResolvedValue('Example Domain'),
|
||||
};
|
||||
|
||||
const browser = {
|
||||
getPage: vi.fn().mockReturnValue(page),
|
||||
setTargetUrl: vi.fn().mockResolvedValue(undefined),
|
||||
triggerTabGroupingForActivePage: vi.fn().mockImplementation(() => {
|
||||
throw new Error('plugin-unavailable');
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await executeCommand(
|
||||
{ id: 'n1', action: 'navigate', url: 'https://example.com', riskMode: 'off' },
|
||||
browser as any
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(page.goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should keep tab_new successful when tab grouping trigger throws after navigation', async () => {
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const browser = {
|
||||
newTab: vi.fn().mockResolvedValue({ index: 1, total: 2 }),
|
||||
getPage: vi.fn().mockReturnValue(page),
|
||||
triggerTabGroupingForActivePage: vi.fn().mockImplementation(() => {
|
||||
throw new Error('plugin-unavailable');
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await executeCommand(
|
||||
{ id: 't1', action: 'tab_new', url: 'https://example.com' },
|
||||
browser as any
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(browser.newTab).toHaveBeenCalledTimes(1);
|
||||
expect(page.goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('risk interstitial recovery', () => {
|
||||
it('should wait for cloudflare-style challenge to clear before retrying navigation', async () => {
|
||||
const challengeClearMs = 10_000;
|
||||
let challengeElapsed = 0;
|
||||
let currentUrl = 'https://dash.cloudflare.com/challenge';
|
||||
let currentTitle = 'Just a moment...';
|
||||
|
||||
const syncChallengeState = () => {
|
||||
if (challengeElapsed >= challengeClearMs) {
|
||||
currentUrl = 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm';
|
||||
currentTitle = 'Cloudflare Dashboard';
|
||||
} else {
|
||||
currentUrl = 'https://dash.cloudflare.com/challenge';
|
||||
currentTitle = 'Just a moment...';
|
||||
}
|
||||
};
|
||||
|
||||
const page = {
|
||||
waitForTimeout: vi.fn().mockImplementation(async (ms: number) => {
|
||||
challengeElapsed += Number(ms) || 0;
|
||||
syncChallengeState();
|
||||
}),
|
||||
goto: vi.fn().mockImplementation(async () => {
|
||||
// Refreshing during verification resets challenge progress.
|
||||
if (challengeElapsed < challengeClearMs) {
|
||||
challengeElapsed = 0;
|
||||
}
|
||||
syncChallengeState();
|
||||
}),
|
||||
url: vi.fn().mockImplementation(() => currentUrl),
|
||||
title: vi.fn().mockImplementation(async () => currentTitle),
|
||||
evaluate: vi.fn().mockImplementation(async () => {
|
||||
if (currentTitle === 'Just a moment...') {
|
||||
return 'Performing security verification Verifying... This website uses a security service to protect against malicious bots.';
|
||||
}
|
||||
return 'Dashboard content';
|
||||
}),
|
||||
};
|
||||
|
||||
const browser = {
|
||||
getPage: vi.fn().mockReturnValue(page),
|
||||
setTargetUrl: vi.fn().mockResolvedValue(undefined),
|
||||
triggerTabGroupingForActivePage: vi.fn(),
|
||||
};
|
||||
|
||||
const response = await executeCommand(
|
||||
{
|
||||
id: 'cf1',
|
||||
action: 'navigate',
|
||||
url: 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm',
|
||||
riskMode: 'warn',
|
||||
},
|
||||
browser as any
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
if (response.success) {
|
||||
expect(response.data.title).toBe('Cloudflare Dashboard');
|
||||
expect(response.data.warning).toContain('cleared after wait');
|
||||
expect(response.data.riskSignals?.length ?? 0).toBeGreaterThan(0);
|
||||
}
|
||||
expect(page.goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+122
-4
@@ -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
|
||||
@@ -545,6 +553,11 @@ async function handleNavigate(
|
||||
await page.goto(command.url, {
|
||||
waitUntil: command.waitUntil ?? 'load',
|
||||
});
|
||||
try {
|
||||
browser.triggerTabGroupingForActivePage('navigate');
|
||||
} catch {
|
||||
// Tab-grouping is best-effort and must never fail navigation.
|
||||
}
|
||||
|
||||
const riskMode: RiskMode = command.riskMode ?? 'warn';
|
||||
if (riskMode === 'off') {
|
||||
@@ -557,7 +570,7 @@ async function handleNavigate(
|
||||
// Detect risk interstitials (captcha/verification) and handle by risk mode.
|
||||
const finalUrl = page.url();
|
||||
const title = await page.title();
|
||||
let encounteredSignals = detectRiskSignals(finalUrl, title);
|
||||
let encounteredSignals = await detectPageRiskSignals(page, finalUrl, title);
|
||||
if (encounteredSignals.length === 0) {
|
||||
return successResponse(command.id, {
|
||||
url: finalUrl,
|
||||
@@ -573,16 +586,43 @@ async function handleNavigate(
|
||||
);
|
||||
}
|
||||
|
||||
// Many verification interstitials (e.g. Cloudflare) auto-resolve after a short wait.
|
||||
// Poll before forcing a retry to avoid resetting the challenge loop ourselves.
|
||||
const initialRecovery = await waitForRiskRecovery(page, 12_000);
|
||||
if (initialRecovery.recovered) {
|
||||
return successResponse(command.id, {
|
||||
url: initialRecovery.url,
|
||||
title: initialRecovery.title,
|
||||
warning:
|
||||
'Risk interstitial detected and cleared after wait. Reuse the same browser session for stability.',
|
||||
riskSignals: encounteredSignals,
|
||||
});
|
||||
}
|
||||
encounteredSignals = mergeRiskSignals(encounteredSignals, initialRecovery.signals);
|
||||
|
||||
const maxRetries = 2;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const backoff = 3000 + Math.random() * 4000;
|
||||
await page.waitForTimeout(Math.round(backoff));
|
||||
|
||||
const passiveRecovery = await waitForRiskRecovery(page, 8_000);
|
||||
if (passiveRecovery.recovered) {
|
||||
return successResponse(command.id, {
|
||||
url: passiveRecovery.url,
|
||||
title: passiveRecovery.title,
|
||||
warning:
|
||||
'Risk interstitial detected and recovered after wait. Review riskSignals for evidence.',
|
||||
riskSignals: encounteredSignals,
|
||||
});
|
||||
}
|
||||
encounteredSignals = mergeRiskSignals(encounteredSignals, passiveRecovery.signals);
|
||||
|
||||
await page.goto(command.url, {
|
||||
waitUntil: command.waitUntil ?? 'load',
|
||||
});
|
||||
const retryUrl = page.url();
|
||||
const retryTitle = await page.title();
|
||||
const retrySignals = detectRiskSignals(retryUrl, retryTitle);
|
||||
const retrySignals = await detectPageRiskSignals(page, retryUrl, retryTitle);
|
||||
if (retrySignals.length === 0) {
|
||||
return successResponse(command.id, {
|
||||
url: retryUrl,
|
||||
@@ -600,7 +640,7 @@ async function handleNavigate(
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
warning:
|
||||
'Captcha/verification page detected. Try --headed mode or use --session-name for state persistence.',
|
||||
'Captcha/verification page detected. Keep one stable --session-name and retry in the same browser window.',
|
||||
riskSignals: encounteredSignals,
|
||||
});
|
||||
}
|
||||
@@ -616,12 +656,57 @@ function mergeRiskSignals(current: RiskSignal[], next: RiskSignal[]): RiskSignal
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
async function detectPageRiskSignals(
|
||||
page: Page,
|
||||
currentUrl?: string,
|
||||
currentTitle?: string
|
||||
): Promise<RiskSignal[]> {
|
||||
const url = currentUrl ?? page.url();
|
||||
const title = currentTitle ?? (await page.title());
|
||||
let pageText = '';
|
||||
try {
|
||||
pageText = await page.evaluate(() => {
|
||||
const text = (globalThis as any).document?.body?.innerText ?? '';
|
||||
return String(text).slice(0, 2000);
|
||||
});
|
||||
} catch {
|
||||
// Ignore cross-origin/script-restricted pages; URL/title signals still apply.
|
||||
}
|
||||
return detectRiskSignals(url, title, pageText);
|
||||
}
|
||||
|
||||
async function waitForRiskRecovery(
|
||||
page: Page,
|
||||
timeoutMs: number
|
||||
): Promise<{ recovered: boolean; url: string; title: string; signals: RiskSignal[] }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let url = page.url();
|
||||
let title = await page.title();
|
||||
let signals = await detectPageRiskSignals(page, url, title);
|
||||
|
||||
while (signals.length > 0 && Date.now() < deadline) {
|
||||
const remaining = deadline - Date.now();
|
||||
await page.waitForTimeout(Math.min(1000, Math.max(250, remaining)));
|
||||
url = page.url();
|
||||
title = await page.title();
|
||||
signals = await detectPageRiskSignals(page, url, title);
|
||||
}
|
||||
|
||||
return {
|
||||
recovered: signals.length === 0,
|
||||
url,
|
||||
title,
|
||||
signals,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect verification/captcha interstitials and return structured risk evidence.
|
||||
*/
|
||||
export function detectRiskSignals(url: string, title: string): RiskSignal[] {
|
||||
export function detectRiskSignals(url: string, title: string, pageText: string = ''): RiskSignal[] {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
const lowerTitle = title.toLowerCase();
|
||||
const lowerText = pageText.toLowerCase();
|
||||
const urlPatterns: Array<{ pattern: string; code: string; confidence: number }> = [
|
||||
{ pattern: '/verify/captcha', code: 'captcha_interstitial', confidence: 0.98 },
|
||||
{ pattern: '/captcha', code: 'captcha_interstitial', confidence: 0.95 },
|
||||
@@ -637,12 +722,30 @@ export function detectRiskSignals(url: string, title: string): RiskSignal[] {
|
||||
{ pattern: 'challenge', code: 'verification_interstitial', confidence: 0.8 },
|
||||
{ pattern: 'attention required', code: 'verification_interstitial', confidence: 0.96 },
|
||||
{ pattern: 'just a moment', code: 'verification_interstitial', confidence: 0.95 },
|
||||
{
|
||||
pattern: 'performing security verification',
|
||||
code: 'verification_interstitial',
|
||||
confidence: 0.98,
|
||||
},
|
||||
{ pattern: 'checking your browser', code: 'verification_interstitial', confidence: 0.97 },
|
||||
{ pattern: 'access denied', code: 'access_gate', confidence: 0.86 },
|
||||
{ pattern: '驗證', code: 'verification_interstitial', confidence: 0.88 },
|
||||
{ pattern: '验证', code: 'verification_interstitial', confidence: 0.88 },
|
||||
{ pattern: '人机验证', code: 'captcha_interstitial', confidence: 0.95 },
|
||||
];
|
||||
const textPatterns: Array<{ pattern: string; code: string; confidence: number }> = [
|
||||
{
|
||||
pattern: 'performing security verification',
|
||||
code: 'verification_interstitial',
|
||||
confidence: 0.99,
|
||||
},
|
||||
{
|
||||
pattern: 'this website uses a security service to protect against malicious bots',
|
||||
code: 'bot_challenge',
|
||||
confidence: 0.99,
|
||||
},
|
||||
{ pattern: 'verifying...', code: 'verification_interstitial', confidence: 0.84 },
|
||||
];
|
||||
const signals: RiskSignal[] = [];
|
||||
for (const item of urlPatterns) {
|
||||
if (lowerUrl.includes(item.pattern)) {
|
||||
@@ -664,6 +767,16 @@ export function detectRiskSignals(url: string, title: string): RiskSignal[] {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of textPatterns) {
|
||||
if (lowerText.includes(item.pattern)) {
|
||||
signals.push({
|
||||
code: item.code,
|
||||
source: 'title',
|
||||
evidence: item.pattern,
|
||||
confidence: item.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
return mergeRiskSignals([], signals);
|
||||
}
|
||||
|
||||
@@ -1152,6 +1265,11 @@ async function handleTabNew(
|
||||
if (command.url) {
|
||||
const page = browser.getPage();
|
||||
await page.goto(command.url, { waitUntil: 'domcontentloaded' });
|
||||
try {
|
||||
browser.triggerTabGroupingForActivePage('tab-new-navigate');
|
||||
} catch {
|
||||
// Tab-grouping is best-effort and must never fail tab creation.
|
||||
}
|
||||
}
|
||||
|
||||
return successResponse(command.id, result);
|
||||
|
||||
@@ -262,6 +262,80 @@ describe('BrowserManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab-group plugin handshake', () => {
|
||||
it('should mark plugin capability as available after successful handshake', async () => {
|
||||
const manager = new BrowserManager() as any;
|
||||
manager.tabGroupIntent = {
|
||||
session: 'default',
|
||||
groupTitle: 'Agent Browser Stealth',
|
||||
pluginId: 'plugin-123',
|
||||
};
|
||||
manager.stealthConnectionKind = 'cdp';
|
||||
|
||||
const page = {
|
||||
isClosed: () => false,
|
||||
url: () => 'https://example.com',
|
||||
};
|
||||
|
||||
const requestSpy = vi
|
||||
.spyOn(manager, 'requestTabGroupPlugin')
|
||||
.mockResolvedValue({ ok: true, extensionId: 'plugin-123' });
|
||||
|
||||
await manager.tryApplyTabGrouping(page, 'test');
|
||||
|
||||
expect(manager.getTabGroupCapability('default')).toBe('available');
|
||||
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should silently mark capability unavailable on timeout response', async () => {
|
||||
const manager = new BrowserManager() as any;
|
||||
manager.tabGroupIntent = {
|
||||
session: 'default',
|
||||
groupTitle: 'Agent Browser Stealth',
|
||||
pluginId: 'plugin-123',
|
||||
};
|
||||
manager.stealthConnectionKind = 'cdp';
|
||||
|
||||
const page = {
|
||||
isClosed: () => false,
|
||||
url: () => 'https://example.com',
|
||||
};
|
||||
|
||||
const requestSpy = vi.spyOn(manager, 'requestTabGroupPlugin').mockResolvedValue(null);
|
||||
|
||||
await manager.tryApplyTabGrouping(page, 'test-timeout');
|
||||
|
||||
expect(manager.getTabGroupCapability('default')).toBe('unavailable');
|
||||
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should stop retrying handshake once capability is unavailable', async () => {
|
||||
const manager = new BrowserManager() as any;
|
||||
manager.tabGroupIntent = {
|
||||
session: 'default',
|
||||
groupTitle: 'Agent Browser Stealth',
|
||||
pluginId: 'plugin-123',
|
||||
};
|
||||
manager.stealthConnectionKind = 'cdp';
|
||||
|
||||
const page = {
|
||||
isClosed: () => false,
|
||||
url: () => 'https://example.com',
|
||||
};
|
||||
|
||||
const requestSpy = vi.spyOn(manager, 'requestTabGroupPlugin').mockResolvedValue(null);
|
||||
|
||||
await manager.tryApplyTabGrouping(page, 'first-attempt');
|
||||
await manager.tryApplyTabGrouping(page, 'second-attempt');
|
||||
|
||||
expect(manager.getTabGroupCapability('default')).toBe('unavailable');
|
||||
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale session recovery (all pages closed)', () => {
|
||||
it('should recover when all pages are closed externally', async () => {
|
||||
const testBrowser = new BrowserManager();
|
||||
|
||||
+678
-7
@@ -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';
|
||||
@@ -32,6 +38,7 @@ import {
|
||||
STEALTH_CHROMIUM_ARGS,
|
||||
applyStealthScripts,
|
||||
applyBrowserLevelStealth,
|
||||
wrapCDPSessionSourceUrlSanitizer,
|
||||
type StealthScriptOptions,
|
||||
} from './stealth.js';
|
||||
|
||||
@@ -127,6 +134,20 @@ interface StealthContextDefaults {
|
||||
}
|
||||
|
||||
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
|
||||
const DEFAULT_TAB_GROUP_NAME = 'Agent Browser Stealth';
|
||||
const DEFAULT_TAB_GROUP_PLUGIN_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const TAB_GROUP_REQUEST_MESSAGE_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
const TAB_GROUP_RESPONSE_MESSAGE_TYPE = 'AB_TAB_GROUP_RESPONSE';
|
||||
const TAB_GROUP_REQUEST_TIMEOUT_MS = 400;
|
||||
|
||||
type TabGroupPluginAvailability = 'unknown' | 'available' | 'unavailable';
|
||||
|
||||
interface TabGroupIntent {
|
||||
session: string;
|
||||
groupTitle: string;
|
||||
pluginId: string;
|
||||
allowedDomains: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||
@@ -161,8 +182,12 @@ 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;
|
||||
private tabGroupCapabilityBySession: Map<string, TabGroupPluginAvailability> = new Map();
|
||||
private tabGroupInFlight: WeakSet<Page> = new WeakSet();
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -295,7 +320,7 @@ export class BrowserManager {
|
||||
|
||||
try {
|
||||
const page = this.getPage();
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const cdp = wrapCDPSessionSourceUrlSanitizer(await page.context().newCDPSession(page));
|
||||
|
||||
if (!envTimezone) {
|
||||
await cdp
|
||||
@@ -451,10 +476,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;
|
||||
@@ -478,6 +542,281 @@ export class BrowserManager {
|
||||
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);
|
||||
}
|
||||
|
||||
private normalizeTabGroupPluginId(pluginId?: string): string | undefined {
|
||||
if (!pluginId) return undefined;
|
||||
const trimmed = pluginId.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.slice(0, 128);
|
||||
}
|
||||
|
||||
private getAgentSessionName(): string {
|
||||
const session = process.env.AGENT_BROWSER_SESSION?.trim();
|
||||
return session && session.length > 0 ? session : 'default';
|
||||
}
|
||||
|
||||
private buildSessionTabGroupTitle(baseTitle: string, session: string): string {
|
||||
const normalizedBase = this.normalizeTabGroupName(baseTitle) ?? DEFAULT_TAB_GROUP_NAME;
|
||||
if (session === 'default') {
|
||||
return normalizedBase;
|
||||
}
|
||||
const withSuffix = `${normalizedBase} • ${session}`;
|
||||
return this.normalizeTabGroupName(withSuffix) ?? normalizedBase;
|
||||
}
|
||||
|
||||
private configureTabGroupIntent(options: LaunchCommand): void {
|
||||
const baseTitle = this.normalizeTabGroupName(options.tabGroup) ?? DEFAULT_TAB_GROUP_NAME;
|
||||
const session = this.getAgentSessionName();
|
||||
const groupTitle = this.buildSessionTabGroupTitle(baseTitle, session);
|
||||
const pluginId =
|
||||
this.normalizeTabGroupPluginId(options.tabGroupPluginId) ??
|
||||
this.normalizeTabGroupPluginId(process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID) ??
|
||||
DEFAULT_TAB_GROUP_PLUGIN_ID;
|
||||
|
||||
this.tabGroupIntent = {
|
||||
session,
|
||||
groupTitle,
|
||||
pluginId,
|
||||
allowedDomains: [...this.allowedDomains],
|
||||
};
|
||||
if (!this.tabGroupCapabilityBySession.has(session)) {
|
||||
this.tabGroupCapabilityBySession.set(session, 'unknown');
|
||||
}
|
||||
}
|
||||
|
||||
private getTabGroupCapability(session: string): TabGroupPluginAvailability {
|
||||
return this.tabGroupCapabilityBySession.get(session) ?? 'unknown';
|
||||
}
|
||||
|
||||
private setTabGroupCapability(session: string, capability: TabGroupPluginAvailability): void {
|
||||
this.tabGroupCapabilityBySession.set(session, capability);
|
||||
}
|
||||
|
||||
private canInjectTabGroupScript(page: Page): boolean {
|
||||
const url = this.getSafePageUrl(page).toLowerCase();
|
||||
if (!url) return false;
|
||||
return (
|
||||
!url.startsWith('chrome://') &&
|
||||
!url.startsWith('chrome-extension://') &&
|
||||
!url.startsWith('devtools://') &&
|
||||
!url.startsWith('edge://')
|
||||
);
|
||||
}
|
||||
|
||||
private logTabGroupDebug(message: string): void {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[DEBUG] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestTabGroupPlugin(
|
||||
page: Page,
|
||||
intent: TabGroupIntent
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
extensionId?: string;
|
||||
error?: string;
|
||||
riskHints?: string[];
|
||||
policy?: {
|
||||
enforced: boolean;
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
} | null> {
|
||||
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const result = await page.evaluate(
|
||||
({
|
||||
requestType,
|
||||
responseType,
|
||||
nonce,
|
||||
session,
|
||||
groupTitle,
|
||||
pluginId,
|
||||
allowedDomains,
|
||||
timeoutMs,
|
||||
}) => {
|
||||
return new Promise<{
|
||||
ok: boolean;
|
||||
extensionId?: string;
|
||||
error?: string;
|
||||
riskHints?: string[];
|
||||
policy?: {
|
||||
enforced: boolean;
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
} | null>((resolve) => {
|
||||
const win = globalThis as any;
|
||||
let settled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const finish = (
|
||||
value: {
|
||||
ok: boolean;
|
||||
extensionId?: string;
|
||||
error?: string;
|
||||
riskHints?: string[];
|
||||
policy?: {
|
||||
enforced: boolean;
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
} | null
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
win.removeEventListener('message', onMessage);
|
||||
if (typeof timer === 'number') {
|
||||
win.clearTimeout(timer);
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
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;
|
||||
finish({
|
||||
ok: data.ok === true,
|
||||
extensionId:
|
||||
typeof data.extensionId === 'string' && data.extensionId.length > 0
|
||||
? data.extensionId
|
||||
: undefined,
|
||||
error: typeof data.error === 'string' ? data.error : undefined,
|
||||
riskHints: Array.isArray(data.riskHints)
|
||||
? data.riskHints.filter((item): item is string => typeof item === 'string')
|
||||
: undefined,
|
||||
policy:
|
||||
data.policy && typeof data.policy === 'object'
|
||||
? {
|
||||
enforced: (data.policy as Record<string, unknown>).enforced === true,
|
||||
blocked: (data.policy as Record<string, unknown>).blocked === true,
|
||||
reason:
|
||||
typeof (data.policy as Record<string, unknown>).reason === 'string'
|
||||
? ((data.policy as Record<string, unknown>).reason as string)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
win.addEventListener('message', onMessage);
|
||||
timer = win.setTimeout(() => finish(null), timeoutMs);
|
||||
|
||||
try {
|
||||
win.postMessage(
|
||||
{
|
||||
type: requestType,
|
||||
nonce,
|
||||
session,
|
||||
groupTitle,
|
||||
pluginId,
|
||||
allowedDomains,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
finish({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
requestType: TAB_GROUP_REQUEST_MESSAGE_TYPE,
|
||||
responseType: TAB_GROUP_RESPONSE_MESSAGE_TYPE,
|
||||
nonce,
|
||||
session: intent.session,
|
||||
groupTitle: intent.groupTitle,
|
||||
pluginId: intent.pluginId,
|
||||
allowedDomains: intent.allowedDomains,
|
||||
timeoutMs: TAB_GROUP_REQUEST_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private scheduleTabGrouping(page: Page, source: string): void {
|
||||
void this.tryApplyTabGrouping(page, source);
|
||||
}
|
||||
|
||||
private async tryApplyTabGrouping(page: Page, source: string): Promise<void> {
|
||||
const intent = this.tabGroupIntent;
|
||||
if (!intent) return;
|
||||
if (this.stealthConnectionKind !== 'cdp') return;
|
||||
if (this.tabGroupInFlight.has(page)) return;
|
||||
|
||||
const capability = this.getTabGroupCapability(intent.session);
|
||||
if (capability === 'unavailable') return;
|
||||
|
||||
if (page.isClosed() || !this.canInjectTabGroupScript(page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabGroupInFlight.add(page);
|
||||
|
||||
try {
|
||||
const response = await this.requestTabGroupPlugin(page, intent);
|
||||
if (!response) {
|
||||
this.setTabGroupCapability(intent.session, 'unavailable');
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin unavailable (timeout, source=${source}, session=${intent.session})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
this.setTabGroupCapability(intent.session, 'unavailable');
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin returned error (source=${source}, session=${intent.session}): ${response.error ?? 'unknown'}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.extensionId !== intent.pluginId) {
|
||||
this.setTabGroupCapability(intent.session, 'unavailable');
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin id mismatch (source=${source}, expected=${intent.pluginId}, actual=${response.extensionId ?? 'missing'})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setTabGroupCapability(intent.session, 'available');
|
||||
if (response.policy?.blocked) {
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group policy blocked navigation (source=${source}, session=${intent.session}): ${response.policy.reason ?? 'domain-not-allowed'}`
|
||||
);
|
||||
}
|
||||
if (response.riskHints && response.riskHints.length > 0) {
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin risk hints (source=${source}, session=${intent.session}): ${response.riskHints.join(' | ')}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.setTabGroupCapability(intent.session, 'unavailable');
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin unavailable (source=${source}, session=${intent.session}): ${message}`
|
||||
);
|
||||
} finally {
|
||||
this.tabGroupInFlight.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
triggerTabGroupingForActivePage(source: string = 'active-page'): void {
|
||||
if (!this.tabGroupIntent || this.pages.length === 0) return;
|
||||
const page = this.getPage();
|
||||
this.scheduleTabGrouping(page, source);
|
||||
}
|
||||
|
||||
// CDP profiling state
|
||||
private static readonly MAX_PROFILE_EVENTS = 5_000_000;
|
||||
private profilingActive: boolean = false;
|
||||
@@ -1588,14 +1927,14 @@ export class BrowserManager {
|
||||
async launch(options: LaunchCommand): Promise<void> {
|
||||
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
||||
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;
|
||||
|
||||
if (hasExtensions && cdpEndpoint) {
|
||||
if (configuredExtensions.length > 0 && cdpEndpoint) {
|
||||
throw new Error('Extensions cannot be used with CDP connection');
|
||||
}
|
||||
|
||||
if (hasStorageState && hasExtensions) {
|
||||
if (hasStorageState && configuredExtensions.length > 0) {
|
||||
throw new Error(
|
||||
'Storage state cannot be used with extensions (extensions require persistent context)'
|
||||
);
|
||||
@@ -1630,6 +1969,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;
|
||||
|
||||
@@ -1645,6 +1985,7 @@ export class BrowserManager {
|
||||
this.stealthConnectionKind = 'local';
|
||||
}
|
||||
this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
|
||||
const hasExtensions = configuredExtensions.length > 0;
|
||||
|
||||
if (options.downloadPath) {
|
||||
this.downloadPath = options.downloadPath;
|
||||
@@ -1658,6 +1999,7 @@ export class BrowserManager {
|
||||
this.allowedDomains = parseDomainList(envDomains);
|
||||
}
|
||||
}
|
||||
this.configureTabGroupIntent(options);
|
||||
|
||||
if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
|
||||
const warning =
|
||||
@@ -1785,7 +2127,7 @@ export class BrowserManager {
|
||||
let context: BrowserContext;
|
||||
if (hasExtensions) {
|
||||
// Extensions require persistent context in a temp directory
|
||||
const extPaths = options.extensions!.join(',');
|
||||
const extPaths = configuredExtensions.join(',');
|
||||
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
// Combine extension args with custom args and file access args
|
||||
const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
|
||||
@@ -1914,6 +2256,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());
|
||||
@@ -2030,6 +2382,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);
|
||||
@@ -2279,6 +2633,317 @@ 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 } : {}) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether CDP Runtime.evaluate responses still leak automation-only
|
||||
* sourceURL labels such as `__playwright_evaluation_script__`.
|
||||
*/
|
||||
private async runDoctorSourceUrlProbe(checks: DoctorCheck[], launched: boolean): Promise<void> {
|
||||
if (!launched) {
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'cdp:sourceurl-sanitized',
|
||||
'skip',
|
||||
'Browser is not launched; sourceURL probe skipped'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cdp = await this.getCDPSession();
|
||||
const response = await cdp.send('Runtime.evaluate', {
|
||||
expression:
|
||||
"(() => { throw new Error('doctor-sourceurl'); })()\\n//# sourceURL=__playwright_evaluation_script__",
|
||||
returnByValue: true,
|
||||
});
|
||||
const raw = JSON.stringify(response);
|
||||
const leakedMarkers = [
|
||||
'__playwright_evaluation_script__',
|
||||
'__puppeteer_evaluation_script__',
|
||||
'sourceURL=',
|
||||
].filter((marker) => raw.includes(marker));
|
||||
const leaked = leakedMarkers.length > 0;
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'cdp:sourceurl-sanitized',
|
||||
leaked ? 'fail' : 'pass',
|
||||
leaked
|
||||
? 'CDP Runtime.evaluate response still exposes automation sourceURL markers'
|
||||
: 'CDP Runtime.evaluate response is sourceURL-sanitized',
|
||||
leaked ? { leakedMarkers } : undefined
|
||||
);
|
||||
} catch (error) {
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'cdp:sourceurl-sanitized',
|
||||
'warn',
|
||||
`Unable to run Runtime.evaluate sourceURL probe: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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, sourceURL sanitization, and
|
||||
* tab-group plugin readiness/handshake.
|
||||
* This is intentionally side-effect-light: it does not navigate or force launch.
|
||||
*/
|
||||
async runDoctor(): Promise<DoctorData> {
|
||||
const checks: DoctorCheck[] = [];
|
||||
const launched = this.isLaunched();
|
||||
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,
|
||||
})),
|
||||
}
|
||||
);
|
||||
|
||||
await this.runDoctorSourceUrlProbe(checks, launched);
|
||||
|
||||
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 (!launched) {
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:handshake-context',
|
||||
'skip',
|
||||
'Browser is not launched; plugin context check skipped'
|
||||
);
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:tab-group-handshake',
|
||||
pluginResult.status,
|
||||
pluginResult.message,
|
||||
{ configuredPluginId: pluginIntent.pluginId }
|
||||
);
|
||||
} else if (this.stealthConnectionKind !== 'cdp') {
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:handshake-context',
|
||||
'skip',
|
||||
`Current connection mode is ${this.stealthConnectionKind}; plugin context check only applies to 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()) {
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:handshake-context',
|
||||
'fail',
|
||||
'Active page is closed; cannot test plugin handshake context'
|
||||
);
|
||||
pluginResult.status = 'fail';
|
||||
pluginResult.message = 'Active page is closed; cannot run plugin handshake';
|
||||
} else if (!this.canInjectTabGroupScript(page)) {
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:handshake-context',
|
||||
'warn',
|
||||
'Active page is an internal browser page; open a normal http(s) page before testing plugin handshake',
|
||||
{ url: this.getSafePageUrl(page) }
|
||||
);
|
||||
pluginResult.status = 'warn';
|
||||
pluginResult.message =
|
||||
'Active page is an internal browser page; open a normal http(s) page to test plugin handshake';
|
||||
} else {
|
||||
this.addDoctorCheck(
|
||||
checks,
|
||||
'plugin:handshake-context',
|
||||
'pass',
|
||||
'Active page is a normal page; plugin handshake can be tested',
|
||||
{ url: this.getSafePageUrl(page) }
|
||||
);
|
||||
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
|
||||
*/
|
||||
@@ -2343,6 +3008,8 @@ export class BrowserManager {
|
||||
// Invalidate CDP session since the active page changed
|
||||
this.invalidateCDPSession().catch(() => {});
|
||||
}
|
||||
|
||||
this.scheduleTabGrouping(page, 'context-page');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2365,6 +3032,7 @@ export class BrowserManager {
|
||||
this.setupPageTracking(page);
|
||||
}
|
||||
this.activePageIndex = this.pages.length - 1;
|
||||
this.scheduleTabGrouping(page, 'new-tab');
|
||||
|
||||
return { index: this.activePageIndex, total: this.pages.length };
|
||||
}
|
||||
@@ -2506,7 +3174,7 @@ export class BrowserManager {
|
||||
const context = page.context();
|
||||
|
||||
// Create a new CDP session attached to the page
|
||||
this.cdpSession = await context.newCDPSession(page);
|
||||
this.cdpSession = wrapCDPSessionSourceUrlSanitizer(await context.newCDPSession(page));
|
||||
return this.cdpSession;
|
||||
}
|
||||
|
||||
@@ -3135,6 +3803,9 @@ export class BrowserManager {
|
||||
this.contextTimezoneId = undefined;
|
||||
this.contextHeaders = undefined;
|
||||
this.contextUserAgent = undefined;
|
||||
this.tabGroupIntent = null;
|
||||
this.tabGroupCapabilityBySession.clear();
|
||||
this.tabGroupInFlight = new WeakSet();
|
||||
this.refMap = {};
|
||||
this.lastSnapshot = '';
|
||||
this.frameCallback = null;
|
||||
|
||||
+59
-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
|
||||
@@ -464,6 +466,8 @@ export async function startDaemon(options?: {
|
||||
colorSchemeEnv === 'no-preference'
|
||||
? colorSchemeEnv
|
||||
: undefined;
|
||||
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
|
||||
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
|
||||
const launchOptions = {
|
||||
id: 'auto',
|
||||
action: 'launch' as const,
|
||||
@@ -478,6 +482,9 @@ export async function startDaemon(options?: {
|
||||
allowFileAccess: allowFileAccess,
|
||||
|
||||
colorScheme,
|
||||
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
|
||||
tabGroupPluginId:
|
||||
tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined,
|
||||
autoStateFilePath: getSessionAutoStatePath(),
|
||||
};
|
||||
|
||||
@@ -492,6 +499,8 @@ export async function startDaemon(options?: {
|
||||
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
|
||||
colorScheme: launchOptions.colorScheme,
|
||||
userAgent: launchOptions.userAgent,
|
||||
tabGroup: launchOptions.tabGroup,
|
||||
tabGroupPluginId: launchOptions.tabGroupPluginId,
|
||||
};
|
||||
await manager.launch({
|
||||
...cdpLaunchOptions,
|
||||
@@ -518,6 +527,8 @@ export async function startDaemon(options?: {
|
||||
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
|
||||
colorScheme: launchOptions.colorScheme,
|
||||
userAgent: launchOptions.userAgent,
|
||||
tabGroup: launchOptions.tabGroup,
|
||||
tabGroupPluginId: launchOptions.tabGroupPluginId,
|
||||
});
|
||||
attachedToExistingBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
@@ -539,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 &&
|
||||
|
||||
@@ -16,6 +16,17 @@ describe('parseCommand', () => {
|
||||
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', () => {
|
||||
@@ -1453,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);
|
||||
|
||||
@@ -51,6 +51,8 @@ const launchSchema = baseCommandSchema.extend({
|
||||
allowFileAccess: z.boolean().optional(),
|
||||
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
||||
downloadPath: z.string().optional(),
|
||||
tabGroup: z.string().min(1).optional(),
|
||||
tabGroupPluginId: z.string().min(1).optional(),
|
||||
storageState: z.string().optional(),
|
||||
allowedDomains: z.array(z.string()).optional(),
|
||||
actionPolicy: z.string().optional(),
|
||||
@@ -846,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'),
|
||||
@@ -953,6 +959,7 @@ const commandSchema = z.discriminatedUnion('action', [
|
||||
hoverSchema,
|
||||
contentSchema,
|
||||
closeSchema,
|
||||
doctorSchema,
|
||||
tabNewSchema,
|
||||
tabListSchema,
|
||||
tabSwitchSchema,
|
||||
|
||||
@@ -76,6 +76,20 @@ describe('Stealth mode', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps navigator.vendor aligned with Chrome', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const vendorSignals = await browser.getPage().evaluate(() => ({
|
||||
userAgent: navigator.userAgent,
|
||||
vendor: navigator.vendor,
|
||||
}));
|
||||
|
||||
if (vendorSignals.userAgent.includes('Chrome/')) {
|
||||
expect(vendorSignals.vendor).toBe('Google Inc.');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
@@ -170,6 +184,147 @@ describe('Stealth mode', () => {
|
||||
expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true);
|
||||
});
|
||||
|
||||
it('exposes legacy chrome.app/csi/loadTimes APIs', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const signals = await browser.getPage().evaluate(() => {
|
||||
const chromeObj = (window as any).chrome;
|
||||
const csi = chromeObj && typeof chromeObj.csi === 'function' ? chromeObj.csi() : null;
|
||||
const loadTimes =
|
||||
chromeObj && typeof chromeObj.loadTimes === 'function' ? chromeObj.loadTimes() : null;
|
||||
return {
|
||||
hasChrome: !!chromeObj,
|
||||
hasApp: !!(chromeObj && chromeObj.app),
|
||||
appInstalled: chromeObj?.app?.isInstalled,
|
||||
appRunningState: chromeObj?.app?.runningState?.(),
|
||||
hasCsi: typeof chromeObj?.csi === 'function',
|
||||
hasLoadTimes: typeof chromeObj?.loadTimes === 'function',
|
||||
csiHasOnloadT: csi && typeof csi.onloadT === 'number',
|
||||
csiHasPageT: csi && typeof csi.pageT === 'number',
|
||||
loadTimesHasRequestTime: loadTimes && typeof loadTimes.requestTime === 'number',
|
||||
loadTimesHasConnectionInfo: loadTimes && typeof loadTimes.connectionInfo === 'string',
|
||||
};
|
||||
});
|
||||
|
||||
expect(signals.hasChrome).toBe(true);
|
||||
expect(signals.hasApp).toBe(true);
|
||||
expect(signals.appInstalled).toBe(false);
|
||||
expect(signals.appRunningState).toBe('cannot_run');
|
||||
expect(signals.hasCsi).toBe(true);
|
||||
expect(signals.hasLoadTimes).toBe(true);
|
||||
expect(signals.csiHasOnloadT).toBe(true);
|
||||
expect(signals.csiHasPageT).toBe(true);
|
||||
expect(signals.loadTimesHasRequestTime).toBe(true);
|
||||
expect(signals.loadTimesHasConnectionInfo).toBe(true);
|
||||
});
|
||||
|
||||
it('spoofs high-signal media codec probes', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const codecs = await browser.getPage().evaluate(() => {
|
||||
const video = document.createElement('video');
|
||||
const audio = document.createElement('audio');
|
||||
return {
|
||||
mp4Avc: video.canPlayType('video/mp4; codecs="avc1.42E01E"'),
|
||||
xM4a: audio.canPlayType('audio/x-m4a;'),
|
||||
aac: audio.canPlayType('audio/aac'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(codecs.mp4Avc).toBe('probably');
|
||||
expect(codecs.xM4a).toBe('maybe');
|
||||
expect(codecs.aac).toBe('probably');
|
||||
});
|
||||
|
||||
it('patches srcdoc iframe.contentWindow probes', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const iframeSignals = await browser.getPage().evaluate(() => {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.srcdoc = '<!doctype html><html><body>ok</body></html>';
|
||||
const win = iframe.contentWindow;
|
||||
return {
|
||||
hasContentWindow: !!win,
|
||||
selfEqualsWindow: win ? win.self === win : false,
|
||||
selfEqualsTop: win ? win.self === window.top : null,
|
||||
frameElementMatches: win ? win.frameElement === iframe : false,
|
||||
zeroSlotType: typeof (win as any)?.[0],
|
||||
};
|
||||
});
|
||||
|
||||
expect(iframeSignals.hasContentWindow).toBe(true);
|
||||
expect(iframeSignals.selfEqualsWindow).toBe(true);
|
||||
expect(iframeSignals.selfEqualsTop).toBe(false);
|
||||
expect(iframeSignals.frameElementMatches).toBe(true);
|
||||
expect(iframeSignals.zeroSlotType).toBe('undefined');
|
||||
});
|
||||
|
||||
it('sanitizes Playwright sourceURL markers in error stacks', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const stacks = await browser.getPage().evaluate(() => {
|
||||
const explicitEvalStack = eval(
|
||||
`(() => { try { throw new Error('explicit'); } catch (error) { return String(error.stack || ''); } })()\n//# sourceURL=__playwright_evaluation_script__`
|
||||
);
|
||||
let directStack = '';
|
||||
try {
|
||||
throw new Error('direct');
|
||||
} catch (error) {
|
||||
directStack = String((error as Error).stack || '');
|
||||
}
|
||||
return { explicitEvalStack, directStack };
|
||||
});
|
||||
|
||||
expect(stacks.explicitEvalStack).not.toContain('__playwright_evaluation_script__');
|
||||
expect(stacks.explicitEvalStack).not.toContain('__puppeteer_evaluation_script__');
|
||||
expect(stacks.explicitEvalStack).not.toContain('sourceURL=');
|
||||
expect(stacks.directStack).not.toContain('__playwright_evaluation_script__');
|
||||
expect(stacks.directStack).not.toContain('__puppeteer_evaluation_script__');
|
||||
});
|
||||
|
||||
it('sanitizes sourceURL markers in direct CDP Runtime.evaluate payloads', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const cdp = await browser.getCDPSession();
|
||||
const response = await cdp.send('Runtime.evaluate', {
|
||||
expression:
|
||||
"(() => { throw new Error('cdp'); })()\\n//# sourceURL=__playwright_evaluation_script__",
|
||||
returnByValue: true,
|
||||
});
|
||||
const raw = JSON.stringify(response);
|
||||
expect(raw).not.toContain('__playwright_evaluation_script__');
|
||||
expect(raw).not.toContain('__puppeteer_evaluation_script__');
|
||||
expect(raw).not.toContain('sourceURL=');
|
||||
});
|
||||
|
||||
it('doctor reports CDP sourceURL probe as pass in launched chromium sessions', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const report = await browser.runDoctor();
|
||||
const check = report.checks.find((entry) => entry.name === 'cdp:sourceurl-sanitized');
|
||||
|
||||
expect(check).toBeDefined();
|
||||
expect(check?.status).toBe('pass');
|
||||
});
|
||||
|
||||
it('doctor marks plugin handshake context as skip outside CDP mode', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const report = await browser.runDoctor();
|
||||
const check = report.checks.find((entry) => entry.name === 'plugin:handshake-context');
|
||||
|
||||
expect(check).toBeDefined();
|
||||
expect(check?.status).toBe('skip');
|
||||
expect(check?.message).toContain('only applies to CDP');
|
||||
});
|
||||
|
||||
it('exposes contacts manager and content index APIs', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
@@ -222,4 +377,36 @@ describe('Stealth mode', () => {
|
||||
expect(workerSignals.hasDownlinkMaxOnProto).toBe(true);
|
||||
expect(typeof workerSignals.downlinkMax).toBe('number');
|
||||
});
|
||||
|
||||
it('skips worker wrapping for cross-origin blob URLs', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true, stealth: true });
|
||||
|
||||
const signals = await browser.getPage().evaluate(() => {
|
||||
const nativeCreateObjectURL = URL.createObjectURL;
|
||||
const nativeRevokeObjectURL = URL.revokeObjectURL;
|
||||
let createCalls = 0;
|
||||
let revokeCalls = 0;
|
||||
|
||||
(URL as any).createObjectURL = (...args: unknown[]) => {
|
||||
createCalls += 1;
|
||||
return nativeCreateObjectURL.apply(URL, args as [Blob | MediaSource]);
|
||||
};
|
||||
(URL as any).revokeObjectURL = (...args: unknown[]) => {
|
||||
revokeCalls += 1;
|
||||
return nativeRevokeObjectURL.apply(URL, args as [string]);
|
||||
};
|
||||
|
||||
try {
|
||||
new Worker('blob:https://challenges.cloudflare.com/11111111-1111-1111-1111-111111111111');
|
||||
} catch {}
|
||||
|
||||
(URL as any).createObjectURL = nativeCreateObjectURL;
|
||||
(URL as any).revokeObjectURL = nativeRevokeObjectURL;
|
||||
return { createCalls, revokeCalls };
|
||||
});
|
||||
|
||||
expect(signals.createCalls).toBe(0);
|
||||
expect(signals.revokeCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+589
-7
@@ -12,6 +12,7 @@ export interface StealthScriptOptions {
|
||||
locale?: string;
|
||||
userAgent?: string;
|
||||
acceptLanguage?: string;
|
||||
allowWebGLContextFallback?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,90 @@ export const STEALTH_CHROMIUM_ARGS: string[] = [
|
||||
'--use-angle=default',
|
||||
];
|
||||
|
||||
const CDP_SOURCE_URL_SANITIZED = Symbol('ab.cdpSourceUrlSanitized');
|
||||
|
||||
interface CDPSessionLike {
|
||||
send(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
||||
[CDP_SOURCE_URL_SANITIZED]?: boolean;
|
||||
}
|
||||
|
||||
function stripSourceUrlLabels(input: string): string {
|
||||
let output = input;
|
||||
output = output.replace(/\n?\s*\/\/[@#]\s*sourceURL=[^\n\r]*/gi, '');
|
||||
output = output.replace(/\n?\s*\/\*[@#]\s*sourceURL=[\s\S]*?\*\//gi, '');
|
||||
return output;
|
||||
}
|
||||
|
||||
function sanitizeCdpPayload(
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!params || typeof params !== 'object') return params;
|
||||
const sanitizeField = (
|
||||
payload: Record<string, unknown>,
|
||||
field: 'expression' | 'functionDeclaration' | 'source'
|
||||
): Record<string, unknown> => {
|
||||
const value = payload[field];
|
||||
if (typeof value !== 'string') return payload;
|
||||
const cleaned = stripSourceUrlLabels(value);
|
||||
if (cleaned === value) return payload;
|
||||
return { ...payload, [field]: cleaned };
|
||||
};
|
||||
|
||||
switch (method) {
|
||||
case 'Runtime.evaluate':
|
||||
case 'Runtime.compileScript':
|
||||
return sanitizeField(params, 'expression');
|
||||
case 'Runtime.callFunctionOn':
|
||||
return sanitizeField(params, 'functionDeclaration');
|
||||
case 'Page.addScriptToEvaluateOnNewDocument':
|
||||
return sanitizeField(params, 'source');
|
||||
default:
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch CDPSession.send so Runtime/Page script payloads no longer carry
|
||||
* sourceURL labels that reveal automation internals.
|
||||
*/
|
||||
export function wrapCDPSessionSourceUrlSanitizer<T extends CDPSessionLike>(session: T): T {
|
||||
if (!session || typeof session.send !== 'function') return session;
|
||||
if (session[CDP_SOURCE_URL_SANITIZED]) return session;
|
||||
|
||||
const nativeSend = session.send.bind(session);
|
||||
const wrappedSend = (method: string, params?: Record<string, unknown>) => {
|
||||
return nativeSend(method, sanitizeCdpPayload(method, params));
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(session, 'send', {
|
||||
value: wrappedSend,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
(session as any).send = wrappedSend;
|
||||
} catch {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Object.defineProperty(session, CDP_SOURCE_URL_SANITIZED, {
|
||||
value: true,
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
});
|
||||
} catch {
|
||||
(session as any)[CDP_SOURCE_URL_SANITIZED] = true;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all stealth patches to a BrowserContext.
|
||||
* Must be called BEFORE any page is created / navigated.
|
||||
@@ -51,7 +136,7 @@ export async function applyBrowserLevelStealth(
|
||||
options: StealthScriptOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cdp = await (browser as any).newBrowserCDPSession();
|
||||
const cdp = wrapCDPSessionSourceUrlSanitizer(await (browser as any).newBrowserCDPSession());
|
||||
const version = await cdp.send('Browser.getVersion');
|
||||
const rawUA = version?.userAgent ?? '';
|
||||
const explicitUA = options.userAgent?.trim();
|
||||
@@ -93,7 +178,7 @@ async function applyCDPStealthToPage(
|
||||
options: StealthScriptOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const cdp = wrapCDPSessionSourceUrlSanitizer(await page.context().newCDPSession(page));
|
||||
const ua = await cdp.send('Browser.getVersion').catch(() => null);
|
||||
const rawUA = ua?.userAgent ?? '';
|
||||
const explicitUA = options.userAgent?.trim();
|
||||
@@ -196,7 +281,11 @@ function deriveLanguages(locale?: string): string[] {
|
||||
function buildStealthScript(options: StealthScriptOptions): string {
|
||||
const locale = normalizeLocale(options.locale) ?? 'en-US';
|
||||
const languages = deriveLanguages(locale);
|
||||
const configScript = `const __abStealth = ${JSON.stringify({ locale, languages })};`;
|
||||
const configScript = `const __abStealth = ${JSON.stringify({
|
||||
locale,
|
||||
languages,
|
||||
allowWebGLContextFallback: options.allowWebGLContextFallback === true,
|
||||
})};`;
|
||||
|
||||
// Each patch is an IIFE so variable scoping is clean
|
||||
return [
|
||||
@@ -204,11 +293,15 @@ function buildStealthScript(options: StealthScriptOptions): string {
|
||||
patchNavigatorWebdriver(),
|
||||
patchCssSupportsWebdriverHeuristic(),
|
||||
patchChromeRuntime(),
|
||||
patchChromeLegacyApis(),
|
||||
patchIframeContentWindow(),
|
||||
patchNavigatorLanguages(),
|
||||
patchNavigatorVendor(),
|
||||
patchNavigatorPluginsAndMimeTypes(),
|
||||
patchNavigatorPermissions(),
|
||||
patchWebGLVendor(),
|
||||
patchCdcProperties(),
|
||||
patchSourceUrlStackTraces(),
|
||||
patchWindowDimensions(),
|
||||
patchScreenDimensions(),
|
||||
patchScreenAvailability(),
|
||||
@@ -222,6 +315,7 @@ function buildStealthScript(options: StealthScriptOptions): string {
|
||||
patchContentIndex(),
|
||||
patchPrefersColorSchemeHeuristic(),
|
||||
patchPdfViewerEnabled(),
|
||||
patchMediaCodecs(),
|
||||
patchMediaDevices(),
|
||||
patchUserAgentData(),
|
||||
patchUserAgent(),
|
||||
@@ -339,6 +433,223 @@ function patchChromeRuntime(): string {
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add deprecated-but-still-probed Chrome APIs: chrome.app, chrome.csi, chrome.loadTimes.
|
||||
*/
|
||||
function patchChromeLegacyApis(): string {
|
||||
return `(function(){
|
||||
const chromeObject = ('chrome' in window && window.chrome) ? window.chrome : null;
|
||||
if (!chromeObject) return;
|
||||
const nativeNow = Date.now;
|
||||
const nativeToString = Function.prototype.toString;
|
||||
const timing = window.performance && window.performance.timing ? window.performance.timing : null;
|
||||
const getNavigationEntry = () => {
|
||||
try {
|
||||
return performance.getEntriesByType('navigation')[0] || { nextHopProtocol: 'h2', type: 'other' };
|
||||
} catch {
|
||||
return { nextHopProtocol: 'h2', type: 'other' };
|
||||
}
|
||||
};
|
||||
const defineValue = (target, key, value) => {
|
||||
try {
|
||||
Object.defineProperty(target, key, {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const patchFunctionShape = (fn, name) => {
|
||||
try {
|
||||
Object.defineProperty(fn, 'name', { value: name, configurable: true });
|
||||
Object.defineProperty(fn, 'toString', {
|
||||
value: () => nativeToString.call(nativeNow).replace('now', name),
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
if (!('app' in chromeObject)) {
|
||||
const invokeError = (name) => new TypeError('Error in invocation of app.' + name + '()');
|
||||
const app = {
|
||||
isInstalled: false,
|
||||
InstallState: {
|
||||
DISABLED: 'disabled',
|
||||
INSTALLED: 'installed',
|
||||
NOT_INSTALLED: 'not_installed',
|
||||
},
|
||||
RunningState: {
|
||||
CANNOT_RUN: 'cannot_run',
|
||||
READY_TO_RUN: 'ready_to_run',
|
||||
RUNNING: 'running',
|
||||
},
|
||||
getDetails: function getDetails() {
|
||||
if (arguments.length) throw invokeError('getDetails');
|
||||
return null;
|
||||
},
|
||||
getIsInstalled: function getIsInstalled() {
|
||||
if (arguments.length) throw invokeError('getIsInstalled');
|
||||
return false;
|
||||
},
|
||||
runningState: function runningState() {
|
||||
if (arguments.length) throw invokeError('runningState');
|
||||
return 'cannot_run';
|
||||
},
|
||||
};
|
||||
defineValue(chromeObject, 'app', app);
|
||||
}
|
||||
|
||||
if (!('csi' in chromeObject) && timing) {
|
||||
const csi = function csi() {
|
||||
return {
|
||||
onloadT: timing.domContentLoadedEventEnd,
|
||||
startE: timing.navigationStart,
|
||||
pageT: Date.now() - timing.navigationStart,
|
||||
tran: 15,
|
||||
};
|
||||
};
|
||||
patchFunctionShape(csi, 'csi');
|
||||
defineValue(chromeObject, 'csi', csi);
|
||||
}
|
||||
|
||||
if (!('loadTimes' in chromeObject) && timing) {
|
||||
const toFixed = (num, fixed) => {
|
||||
const matcher = new RegExp('^-?\\\\d+(?:.\\\\d{0,' + (fixed || -1) + '})?');
|
||||
const match = String(num).match(matcher);
|
||||
return match ? match[0] : String(num);
|
||||
};
|
||||
const loadTimes = function loadTimes() {
|
||||
const navigationEntry = getNavigationEntry();
|
||||
const nextHopProtocol = navigationEntry.nextHopProtocol || 'h2';
|
||||
let firstPaint = timing.loadEventEnd / 1000;
|
||||
try {
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
if (paintEntries && paintEntries[0] && typeof paintEntries[0].startTime === 'number') {
|
||||
firstPaint = (paintEntries[0].startTime + performance.timeOrigin) / 1000;
|
||||
}
|
||||
} catch {}
|
||||
return {
|
||||
connectionInfo: nextHopProtocol,
|
||||
npnNegotiatedProtocol: ['h2', 'hq'].includes(nextHopProtocol) ? nextHopProtocol : 'unknown',
|
||||
navigationType: navigationEntry.type || 'other',
|
||||
wasAlternateProtocolAvailable: false,
|
||||
wasFetchedViaSpdy: ['h2', 'hq'].includes(nextHopProtocol),
|
||||
wasNpnNegotiated: ['h2', 'hq'].includes(nextHopProtocol),
|
||||
firstPaintAfterLoadTime: 0,
|
||||
requestTime: timing.navigationStart / 1000,
|
||||
startLoadTime: timing.navigationStart / 1000,
|
||||
commitLoadTime: timing.responseStart / 1000,
|
||||
finishDocumentLoadTime: timing.domContentLoadedEventEnd / 1000,
|
||||
finishLoadTime: timing.loadEventEnd / 1000,
|
||||
firstPaintTime: toFixed(firstPaint, 3),
|
||||
};
|
||||
};
|
||||
patchFunctionShape(loadTimes, 'loadTimes');
|
||||
defineValue(chromeObject, 'loadTimes', loadTimes);
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix srcdoc iframe.contentWindow signals used by classic HEADCHR_IFRAME checks.
|
||||
* We only intercept iframe creation and srcdoc assignment to keep impact minimal.
|
||||
*/
|
||||
function patchIframeContentWindow(): string {
|
||||
return `(function(){
|
||||
if (typeof document === 'undefined' || typeof document.createElement !== 'function') return;
|
||||
const nativeCreateElement = document.createElement.bind(document);
|
||||
const nativeSrcdocDescriptor =
|
||||
typeof HTMLIFrameElement !== 'undefined'
|
||||
? Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc')
|
||||
: null;
|
||||
const srcdocGetter = nativeSrcdocDescriptor && nativeSrcdocDescriptor.get;
|
||||
const srcdocSetter = nativeSrcdocDescriptor && nativeSrcdocDescriptor.set;
|
||||
const iframeProxyMap = new WeakMap();
|
||||
const patchedIframes = new WeakSet();
|
||||
|
||||
const ensureContentWindowProxy = (iframe) => {
|
||||
if (!iframe || iframeProxyMap.has(iframe)) return;
|
||||
try {
|
||||
if (iframe.contentWindow) return;
|
||||
} catch {}
|
||||
const proxy = new Proxy(window, {
|
||||
get(target, key) {
|
||||
if (key === 'self') return proxy;
|
||||
if (key === 'frameElement') return iframe;
|
||||
if (key === '0') return undefined;
|
||||
return Reflect.get(target, key, target);
|
||||
},
|
||||
});
|
||||
iframeProxyMap.set(iframe, proxy);
|
||||
try {
|
||||
Object.defineProperty(iframe, 'contentWindow', {
|
||||
get: () => proxy,
|
||||
set: () => undefined,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const patchIframeSrcdoc = (iframe) => {
|
||||
if (!iframe || patchedIframes.has(iframe)) return;
|
||||
patchedIframes.add(iframe);
|
||||
try {
|
||||
Object.defineProperty(iframe, 'srcdoc', {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (typeof srcdocGetter === 'function') {
|
||||
return srcdocGetter.call(this);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
set(value) {
|
||||
ensureContentWindowProxy(this);
|
||||
if (typeof srcdocSetter === 'function') {
|
||||
srcdocSetter.call(this, value);
|
||||
} else {
|
||||
this.setAttribute('srcdoc', String(value ?? ''));
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const patchedCreateElement = function(...args) {
|
||||
const element = nativeCreateElement(...args);
|
||||
try {
|
||||
const name = args && args.length > 0 ? String(args[0]).toLowerCase() : '';
|
||||
if (name === 'iframe') {
|
||||
patchIframeSrcdoc(element);
|
||||
}
|
||||
} catch {}
|
||||
return element;
|
||||
};
|
||||
try {
|
||||
Object.defineProperty(patchedCreateElement, 'name', {
|
||||
value: 'createElement',
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(patchedCreateElement, 'toString', {
|
||||
value: () => nativeCreateElement.toString(),
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
Object.defineProperty(document, 'createElement', {
|
||||
value: patchedCreateElement,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {
|
||||
try { document.createElement = patchedCreateElement; } catch {}
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep navigator.language + navigator.languages aligned with launch locale.
|
||||
*/
|
||||
@@ -362,6 +673,38 @@ function patchNavigatorLanguages(): string {
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep navigator.vendor aligned with regular Chrome.
|
||||
*/
|
||||
function patchNavigatorVendor(): string {
|
||||
return `(function(){
|
||||
const ua = String(navigator.userAgent || '');
|
||||
if (!/Chrome\\//.test(ua) || /Firefox\\//.test(ua)) return;
|
||||
const target = 'Google Inc.';
|
||||
const proto = Object.getPrototypeOf(navigator);
|
||||
try {
|
||||
if (navigator.vendor === target) return;
|
||||
} catch {}
|
||||
const defineVendor = (targetObj) => {
|
||||
if (!targetObj) return false;
|
||||
try {
|
||||
Object.defineProperty(targetObj, 'vendor', {
|
||||
get: () => target,
|
||||
configurable: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (defineVendor(proto)) {
|
||||
try { delete (navigator).vendor; } catch {}
|
||||
return;
|
||||
}
|
||||
defineVendor(navigator);
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject realistic navigator.plugins and navigator.mimeTypes arrays.
|
||||
* Headless Chrome reports an empty PluginArray; real Chrome always has a few.
|
||||
@@ -500,8 +843,104 @@ function patchNavigatorPermissions(): string {
|
||||
function patchWebGLVendor(): string {
|
||||
return `(function(){
|
||||
const getCtx = HTMLCanvasElement.prototype.getContext;
|
||||
const WEBGL_VENDOR = 'Intel Inc.';
|
||||
const WEBGL_RENDERER = 'Intel Iris OpenGL Engine';
|
||||
const DEBUG_RENDERER_INFO = {
|
||||
UNMASKED_VENDOR_WEBGL: 0x9245,
|
||||
UNMASKED_RENDERER_WEBGL: 0x9246,
|
||||
};
|
||||
|
||||
const createFallbackWebGLContext = (canvas, requestedType) => {
|
||||
const isWebGL2 = requestedType === 'webgl2';
|
||||
const ctx = {
|
||||
__abFallbackWebGLContext: true,
|
||||
canvas,
|
||||
drawingBufferWidth: canvas.width || 300,
|
||||
drawingBufferHeight: canvas.height || 150,
|
||||
VENDOR: 0x1F00,
|
||||
RENDERER: 0x1F01,
|
||||
VERSION: 0x1F02,
|
||||
SHADING_LANGUAGE_VERSION: 0x8B8C,
|
||||
getExtension(name) {
|
||||
if (name === 'WEBGL_debug_renderer_info') return DEBUG_RENDERER_INFO;
|
||||
return null;
|
||||
},
|
||||
getSupportedExtensions() {
|
||||
return ['WEBGL_debug_renderer_info'];
|
||||
},
|
||||
getContextAttributes() {
|
||||
return {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
depth: true,
|
||||
desynchronized: false,
|
||||
failIfMajorPerformanceCaveat: false,
|
||||
powerPreference: 'default',
|
||||
premultipliedAlpha: true,
|
||||
preserveDrawingBuffer: false,
|
||||
stencil: false,
|
||||
};
|
||||
},
|
||||
getParameter(param) {
|
||||
if (param === DEBUG_RENDERER_INFO.UNMASKED_VENDOR_WEBGL || param === this.VENDOR) {
|
||||
return WEBGL_VENDOR;
|
||||
}
|
||||
if (param === DEBUG_RENDERER_INFO.UNMASKED_RENDERER_WEBGL || param === this.RENDERER) {
|
||||
return WEBGL_RENDERER;
|
||||
}
|
||||
if (param === this.VERSION) {
|
||||
return isWebGL2
|
||||
? 'WebGL 2.0 (OpenGL ES 3.0 Chromium)'
|
||||
: 'WebGL 1.0 (OpenGL ES 2.0 Chromium)';
|
||||
}
|
||||
if (param === this.SHADING_LANGUAGE_VERSION) {
|
||||
return isWebGL2
|
||||
? 'WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)'
|
||||
: 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)';
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
getError() { return 0; },
|
||||
clear() {},
|
||||
clearColor() {},
|
||||
createBuffer() { return {}; },
|
||||
bindBuffer() {},
|
||||
bufferData() {},
|
||||
createProgram() { return {}; },
|
||||
createShader() { return {}; },
|
||||
shaderSource() {},
|
||||
compileShader() {},
|
||||
attachShader() {},
|
||||
linkProgram() {},
|
||||
useProgram() {},
|
||||
viewport() {},
|
||||
drawArrays() {},
|
||||
readPixels() {},
|
||||
finish() {},
|
||||
flush() {},
|
||||
};
|
||||
try {
|
||||
const proto =
|
||||
requestedType === 'webgl2' && typeof WebGL2RenderingContext !== 'undefined'
|
||||
? WebGL2RenderingContext.prototype
|
||||
: typeof WebGLRenderingContext !== 'undefined'
|
||||
? WebGLRenderingContext.prototype
|
||||
: null;
|
||||
if (proto) Object.setPrototypeOf(ctx, proto);
|
||||
} catch {}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
|
||||
const ctx = getCtx.call(this, type, attrs);
|
||||
if (
|
||||
(type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') &&
|
||||
!ctx &&
|
||||
__abStealth &&
|
||||
__abStealth.allowWebGLContextFallback === true
|
||||
) {
|
||||
return createFallbackWebGLContext(this, type);
|
||||
}
|
||||
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
|
||||
const origGetParameter = ctx.getParameter.bind(ctx);
|
||||
ctx.getParameter = function(param) {
|
||||
@@ -509,13 +948,15 @@ function patchWebGLVendor(): string {
|
||||
if (ext) {
|
||||
if (param === ext.UNMASKED_VENDOR_WEBGL) {
|
||||
const real = origGetParameter(param);
|
||||
return (real && real.includes('SwiftShader')) ? 'Intel Inc.' : real;
|
||||
return (real && real.includes('SwiftShader')) ? WEBGL_VENDOR : real;
|
||||
}
|
||||
if (param === ext.UNMASKED_RENDERER_WEBGL) {
|
||||
const real = origGetParameter(param);
|
||||
return (real && real.includes('SwiftShader')) ? 'Intel Iris OpenGL Engine' : real;
|
||||
return (real && real.includes('SwiftShader')) ? WEBGL_RENDERER : real;
|
||||
}
|
||||
}
|
||||
if (param === ctx.VENDOR) return WEBGL_VENDOR;
|
||||
if (param === ctx.RENDERER) return WEBGL_RENDERER;
|
||||
return origGetParameter(param);
|
||||
};
|
||||
}
|
||||
@@ -542,6 +983,60 @@ function patchCdcProperties(): string {
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Playwright/Puppeteer sourceURL tokens from error stacks.
|
||||
* This mirrors the intent of the sourceurl evasion: reduce obvious
|
||||
* automation-only script labels in stack traces.
|
||||
*/
|
||||
function patchSourceUrlStackTraces(): string {
|
||||
return `(function(){
|
||||
if (typeof Error === 'undefined') return;
|
||||
const sanitizeStack = (value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
let stack = value;
|
||||
stack = stack.replace(/\\/\\/# sourceURL=.*$/gm, '');
|
||||
stack = stack.replace(/__playwright_evaluation_script__/g, '<anonymous>');
|
||||
stack = stack.replace(/__puppeteer_evaluation_script__/g, '<anonymous>');
|
||||
stack = stack.replace(/__pw_evaluation_script__/g, '<anonymous>');
|
||||
return stack;
|
||||
};
|
||||
|
||||
const nativePrepare = Error.prepareStackTrace;
|
||||
Error.prepareStackTrace = function(error, structuredStackTrace) {
|
||||
let stackString;
|
||||
if (typeof nativePrepare === 'function') {
|
||||
stackString = nativePrepare.call(this, error, structuredStackTrace);
|
||||
} else {
|
||||
const name = error && error.name ? String(error.name) : 'Error';
|
||||
const message = error && error.message ? String(error.message) : '';
|
||||
const header = message ? name + ': ' + message : name;
|
||||
const frames = Array.isArray(structuredStackTrace)
|
||||
? structuredStackTrace.map((frame) => ' at ' + String(frame))
|
||||
: [];
|
||||
stackString = [header].concat(frames).join('\\n');
|
||||
}
|
||||
return sanitizeStack(String(stackString));
|
||||
};
|
||||
|
||||
if (typeof Error.captureStackTrace === 'function') {
|
||||
const nativeCapture = Error.captureStackTrace;
|
||||
Error.captureStackTrace = function(targetObject, constructorOpt) {
|
||||
nativeCapture.call(this, targetObject, constructorOpt);
|
||||
try {
|
||||
const stack = targetObject && targetObject.stack;
|
||||
if (typeof stack === 'string') {
|
||||
Object.defineProperty(targetObject, 'stack', {
|
||||
value: sanitizeStack(stack),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* contentWindow on cross-origin iframes: Playwright sometimes returns null
|
||||
* where real browsers return a (restricted) Window object.
|
||||
@@ -767,7 +1262,8 @@ function patchNavigatorConnection(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure dedicated workers expose navigator.connection.downlinkMax too.
|
||||
* Ensure same-origin dedicated workers expose navigator.connection.downlinkMax too.
|
||||
* Skip cross-origin worker URLs to avoid breaking anti-bot challenge workers.
|
||||
*/
|
||||
function patchWorkerConnection(): string {
|
||||
return `(function(){
|
||||
@@ -810,12 +1306,36 @@ function patchWorkerConnection(): string {
|
||||
: \`importScripts(\${JSON.stringify(scriptUrl)});\`;
|
||||
return \`\${workerPrelude}\\n\${loader}\`;
|
||||
};
|
||||
const resolveWorkerUrl = (value) => {
|
||||
try {
|
||||
return new URL(String(value), location.href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const shouldPatchWorker = (value) => {
|
||||
const resolved = resolveWorkerUrl(value);
|
||||
if (!resolved) return false;
|
||||
if (resolved.protocol === 'blob:') return resolved.origin === location.origin;
|
||||
if (resolved.protocol === 'http:' || resolved.protocol === 'https:') {
|
||||
return resolved.origin === location.origin;
|
||||
}
|
||||
if (resolved.protocol === 'file:') return location.protocol === 'file:';
|
||||
return false;
|
||||
};
|
||||
const WrappedWorker = function(scriptURL, options) {
|
||||
if (!shouldPatchWorker(scriptURL)) {
|
||||
return new NativeWorker(scriptURL, options);
|
||||
}
|
||||
try {
|
||||
const source = buildPatchedScript(scriptURL, options);
|
||||
const blob = new Blob([source], { type: 'application/javascript' });
|
||||
const patchedUrl = URL.createObjectURL(blob);
|
||||
return new NativeWorker(patchedUrl, options);
|
||||
const worker = new NativeWorker(patchedUrl, options);
|
||||
try {
|
||||
setTimeout(() => URL.revokeObjectURL(patchedUrl), 0);
|
||||
} catch {}
|
||||
return worker;
|
||||
} catch {
|
||||
return new NativeWorker(scriptURL, options);
|
||||
}
|
||||
@@ -1016,6 +1536,68 @@ function patchPdfViewerEnabled(): string {
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromium headless can under-report support for common media codecs.
|
||||
* Patch canPlayType for a narrow set of high-signal probes.
|
||||
*/
|
||||
function patchMediaCodecs(): string {
|
||||
return `(function(){
|
||||
if (typeof HTMLMediaElement === 'undefined' || !HTMLMediaElement.prototype) return;
|
||||
const nativeCanPlayType = HTMLMediaElement.prototype.canPlayType;
|
||||
if (typeof nativeCanPlayType !== 'function') return;
|
||||
const parseInput = (value) => {
|
||||
const input = String(value || '').trim();
|
||||
const [mimePart, codecPart] = input.split(';');
|
||||
const mime = String(mimePart || '').trim().toLowerCase();
|
||||
const codecs = [];
|
||||
if (codecPart && codecPart.includes('codecs=')) {
|
||||
const normalized = codecPart
|
||||
.replace(/^[^=]*=/, '')
|
||||
.replace(/^\\s*["']?/, '')
|
||||
.replace(/["']?\\s*$/, '');
|
||||
normalized
|
||||
.split(',')
|
||||
.map((codec) => codec.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.forEach((codec) => codecs.push(codec));
|
||||
}
|
||||
return { mime, codecs };
|
||||
};
|
||||
const patchedCanPlayType = function(type) {
|
||||
const { mime, codecs } = parseInput(type);
|
||||
if (mime === 'video/mp4' && codecs.includes('avc1.42e01e')) {
|
||||
return 'probably';
|
||||
}
|
||||
if (mime === 'audio/x-m4a' && codecs.length === 0) {
|
||||
return 'maybe';
|
||||
}
|
||||
if (mime === 'audio/aac' && codecs.length === 0) {
|
||||
return 'probably';
|
||||
}
|
||||
return nativeCanPlayType.call(this, type);
|
||||
};
|
||||
try {
|
||||
Object.defineProperty(patchedCanPlayType, 'name', {
|
||||
value: 'canPlayType',
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(patchedCanPlayType, 'toString', {
|
||||
value: () => nativeCanPlayType.toString(),
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'canPlayType', {
|
||||
value: patchedCanPlayType,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {
|
||||
try { HTMLMediaElement.prototype.canPlayType = patchedCanPlayType; } catch {}
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* navigator.mediaDevices.enumerateDevices should return at least some devices
|
||||
* instead of an empty array (headless default).
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface LaunchCommand extends BaseCommand {
|
||||
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
||||
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
|
||||
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath)
|
||||
tabGroup?: string; // Base tab-group title (session suffix is appended automatically)
|
||||
tabGroupPluginId?: string; // Expected Chrome extension ID for CDP tab-group handshake
|
||||
allowedDomains?: string[];
|
||||
actionPolicy?: string;
|
||||
confirmActions?: string[];
|
||||
@@ -875,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';
|
||||
@@ -929,6 +935,7 @@ export type Command =
|
||||
| HoverCommand
|
||||
| ContentCommand
|
||||
| CloseCommand
|
||||
| DoctorCommand
|
||||
| TabNewCommand
|
||||
| TabListCommand
|
||||
| TabSwitchCommand
|
||||
@@ -1295,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