* Native Rust rewrite of agent-browser daemon

Single-binary Rust implementation replacing the Node.js/Playwright daemon
with direct CDP (Chrome DevTools Protocol) communication. Includes full
command parity, WebDriver/Safari/iOS backend routing, request tracking,
frame context management, CDP protocol codegen, and comprehensive tests.

* improvements

* fix ci

* fixes

* faster builds
This commit is contained in:
Chris Tate
2026-03-03 15:15:57 -06:00
committed by GitHub
parent 857c0b25df
commit 51f5fa484c
52 changed files with 55160 additions and 278 deletions
+10 -37
View File
@@ -78,24 +78,13 @@ 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
@@ -122,18 +111,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
@@ -258,18 +239,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 }}
+3 -11
View File
@@ -97,18 +97,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
+51
View File
@@ -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
+42 -3
View File
@@ -495,6 +495,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl
| `--action-policy <path>` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) |
| `--confirm-actions <list>` | Action categories requiring confirmation (or `AGENT_BROWSER_CONFIRM_ACTIONS` env) |
| `--confirm-interactive` | Interactive confirmation prompts; auto-denies if stdin is not a TTY (or `AGENT_BROWSER_CONFIRM_INTERACTIVE` env) |
| `--native` | [Experimental] Use native Rust daemon instead of Node.js (or `AGENT_BROWSER_NATIVE` env) |
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
| `--debug` | Debug output |
@@ -910,12 +911,50 @@ await browser.stopScreencast();
agent-browser uses a client-daemon architecture:
1. **Rust CLI** (fast native binary) - Parses commands, communicates with daemon
2. **Node.js Daemon** - Manages Playwright browser instance
3. **Fallback** - If native binary unavailable, uses Node.js directly
2. **Node.js Daemon** (default) - Manages Playwright browser instance
3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required
4. **Fallback** - If native binary unavailable, uses Node.js directly
The daemon starts automatically on first command and persists between commands for fast subsequent operations.
**Browser Engine:** Uses Chromium by default. The daemon also supports Firefox and WebKit via the Playwright protocol.
**Browser Engine:** Uses Chromium by default. The default Node.js daemon also supports Firefox and WebKit via Playwright. The experimental native daemon speaks Chrome DevTools Protocol (CDP) directly and supports Chromium-based browsers and Safari (via WebDriver).
## Experimental: Native Mode
The native daemon is a pure Rust implementation that communicates with Chrome directly via CDP, eliminating the Node.js and Playwright dependencies. It is currently **experimental** and opt-in.
### Enabling Native Mode
```bash
# Via flag
agent-browser --native open example.com
# Via environment variable (recommended for persistent use)
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
```
Or add to your config file (`agent-browser.json`):
```json
{"native": true}
```
### What's Different
| | Default (Node.js) | Native (`--native`) |
|---|---|---|
| **Runtime** | Node.js + Playwright | Pure Rust binary |
| **Protocol** | Playwright protocol | Direct CDP / WebDriver |
| **Install size** | Larger (Node.js + npm deps) | Smaller (single binary) |
| **Browser support** | Chromium, Firefox, WebKit | Chromium, Safari (via WebDriver) |
| **Stability** | Stable | Experimental |
### Known Limitations
- Firefox and WebKit are not yet supported (Chromium and Safari only)
- Some Playwright-specific features (tracing format, HAR export) are not available
- The native daemon and Node.js daemon share the same session socket, so you cannot run both simultaneously for the same session. Use `agent-browser close` before switching modes.
## Platforms
+2663 -8
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -11,6 +11,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 = ["native-tls"] }
futures-util = "0.3"
url = "2"
uuid = { version = "1", features = ["v4"] }
image = "0.25"
reqwest = { version = "0.12", features = ["json"] }
sha2 = "0.10"
aes-gcm = "0.10"
async-trait = "0.1"
similar = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -18,8 +29,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
View File
@@ -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(&param.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(&param.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
+80 -53
View File
@@ -114,10 +114,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
// If --headers flag is set, include headers (scoped to this origin)
if let Some(ref headers_json) = flags.headers {
let headers = serde_json::from_str::<serde_json::Value>(headers_json)
.map_err(|_| ParseError::InvalidValue {
message: format!("Invalid JSON for --headers: {}", headers_json),
usage: "open <url> --headers '{\"Key\": \"Value\"}'",
let headers =
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
ParseError::InvalidValue {
message: format!("Invalid JSON for --headers: {}", headers_json),
usage: "open <url> --headers '{\"Key\": \"Value\"}'",
}
})?;
nav_cmd["headers"] = headers;
}
@@ -290,7 +292,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
usage: "keyboard inserttext <text>",
});
}
Ok(json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }))
Ok(
json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }),
)
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -585,13 +589,33 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let mut j = 2;
while j < rest.len() {
match rest[j].as_ref() {
"--url" => { url = rest.get(j + 1).cloned(); j += 1; }
"--username" => { username = rest.get(j + 1).cloned(); j += 1; }
"--password" => { password = rest.get(j + 1).cloned(); j += 1; }
"--password-stdin" => { password_stdin = true; }
"--username-selector" => { username_selector = rest.get(j + 1).cloned(); j += 1; }
"--password-selector" => { password_selector = rest.get(j + 1).cloned(); j += 1; }
"--submit-selector" => { submit_selector = rest.get(j + 1).cloned(); j += 1; }
"--url" => {
url = rest.get(j + 1).cloned();
j += 1;
}
"--username" => {
username = rest.get(j + 1).cloned();
j += 1;
}
"--password" => {
password = rest.get(j + 1).cloned();
j += 1;
}
"--password-stdin" => {
password_stdin = true;
}
"--username-selector" => {
username_selector = rest.get(j + 1).cloned();
j += 1;
}
"--password-selector" => {
password_selector = rest.get(j + 1).cloned();
j += 1;
}
"--submit-selector" => {
submit_selector = rest.get(j + 1).cloned();
j += 1;
}
other => {
if other.starts_with("--") {
return Err(ParseError::InvalidValue {
@@ -1093,9 +1117,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
})?;
Ok(json!({ "id": id, "action": "state_load", "path": path }))
}
Some("list") => {
Ok(json!({ "id": id, "action": "state_list" }))
}
Some("list") => Ok(json!({ "id": id, "action": "state_list" })),
Some("clear") => {
let mut session_name: Option<&str> = None;
let mut all = false;
@@ -1116,7 +1138,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
if let Some(name) = session_name {
if !is_valid_session_name(name) {
return Err(ParseError::InvalidSessionName { name: name.to_string() });
return Err(ParseError::InvalidSessionName {
name: name.to_string(),
});
}
}
@@ -1170,13 +1194,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let new_name = new_name.trim_end_matches(".json");
if !is_valid_session_name(old_name) {
return Err(ParseError::InvalidSessionName { name: old_name.to_string() });
return Err(ParseError::InvalidSessionName {
name: old_name.to_string(),
});
}
if !is_valid_session_name(new_name) {
return Err(ParseError::InvalidSessionName { name: new_name.to_string() });
return Err(ParseError::InvalidSessionName {
name: new_name.to_string(),
});
}
Ok(json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }))
Ok(
json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }),
)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -1285,7 +1315,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
}
Err(_) => {
return Err(ParseError::InvalidValue {
message: format!("Depth must be a non-negative integer, got: {}", d),
message: format!(
"Depth must be a non-negative integer, got: {}",
d
),
usage: "diff snapshot --depth <n>",
});
}
@@ -1351,7 +1384,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
}
Ok(n) => {
return Err(ParseError::InvalidValue {
message: format!("Threshold must be between 0 and 1, got {}", n),
message: format!(
"Threshold must be between 0 and 1, got {}",
n
),
usage: "diff screenshot --threshold <0-1>",
});
}
@@ -1468,7 +1504,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
}
Err(_) => {
return Err(ParseError::InvalidValue {
message: format!("Depth must be a non-negative integer, got: {}", d),
message: format!(
"Depth must be a non-negative integer, got: {}",
d
),
usage: "diff url <url1> <url2> --depth <n>",
});
}
@@ -2070,7 +2109,7 @@ mod tests {
action_policy: None,
confirm_actions: None,
confirm_interactive: false,
native: false,
}
}
@@ -2374,16 +2413,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");
}
@@ -3236,8 +3271,11 @@ mod tests {
#[test]
fn test_diff_snapshot_baseline() {
let cmd =
parse_command(&args("diff snapshot --baseline before.txt"), &default_flags()).unwrap();
let cmd = parse_command(
&args("diff snapshot --baseline before.txt"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "diff_snapshot");
assert_eq!(cmd["baseline"], "before.txt");
}
@@ -3257,9 +3295,11 @@ mod tests {
#[test]
fn test_diff_snapshot_short_flags() {
let cmd =
parse_command(&args("diff snapshot -b snap.txt -s .content -c -d 2"), &default_flags())
.unwrap();
let cmd = parse_command(
&args("diff snapshot -b snap.txt -s .content -c -d 2"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "diff_snapshot");
assert_eq!(cmd["baseline"], "snap.txt");
assert_eq!(cmd["selector"], ".content");
@@ -3307,8 +3347,7 @@ mod tests {
fn test_diff_screenshot_global_full_flag() {
let mut flags = default_flags();
flags.full = true;
let cmd =
parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
let cmd = parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
assert_eq!(cmd["action"], "diff_screenshot");
assert_eq!(cmd["fullPage"], true);
}
@@ -3352,8 +3391,7 @@ mod tests {
fn test_diff_url_global_full_flag() {
let mut flags = default_flags();
flags.full = true;
let cmd =
parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
let cmd = parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
assert_eq!(cmd["fullPage"], true);
}
@@ -3676,11 +3714,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);
@@ -3689,11 +3723,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);
@@ -3702,11 +3733,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);
+99 -60
View File
@@ -232,6 +232,7 @@ pub struct DaemonOptions<'a> {
pub allowed_domains: Option<&'a [String]>,
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
pub native: bool,
}
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
@@ -294,10 +295,7 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
}
}
pub fn ensure_daemon(
session: &str,
opts: &DaemonOptions,
) -> Result<DaemonResult, String> {
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) {
// Double-check it's actually responsive by waiting and checking again
@@ -366,72 +364,113 @@ pub fn ensure_daemon(
exe_path
}
};
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
if opts.native {
// Native mode: spawn self as daemon (Rust/CDP, no Node.js needed)
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// Check AGENT_BROWSER_HOME environment variable
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
// Spawn daemon as a fully detached background process
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
// Create new session (detach from terminal)
libc::setsid();
Ok(())
});
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?;
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
let mut cmd = Command::new("node.exe");
cmd.arg(daemon_path)
.env("MSYS_NO_PATHCONV", "1")
.env("MSYS2_ARG_CONV_EXCL", "*");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?;
}
} else {
// Default mode: spawn Node.js daemon (Playwright)
let exe_dir = exe_path.parent().unwrap();
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
let mut cmd = Command::new("node.exe");
cmd.arg(daemon_path)
.env("MSYS_NO_PATHCONV", "1")
.env("MSYS2_ARG_CONV_EXCL", "*");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
}
for _ in 0..50 {
+95 -49
View File
@@ -41,6 +41,7 @@ pub struct Config {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub native: Option<bool>,
}
impl Config {
@@ -82,6 +83,7 @@ impl Config {
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
native: other.native.or(self.native),
}
}
}
@@ -180,8 +182,7 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
});
if let Some((source, maybe_path)) = explicit {
let path_str =
maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
let path_str = maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
let path = PathBuf::from(&path_str);
if !path.exists() {
return Err(format!("config file not found: {}", path_str));
@@ -234,6 +235,7 @@ pub struct Flags {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub native: bool,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -273,66 +275,72 @@ pub fn parse_flags(args: &[String]) -> Flags {
};
let mut flags = Flags {
json: env_var_is_truthy("AGENT_BROWSER_JSON")
|| config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL")
|| config.full.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED")
|| config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG")
|| config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION").ok()
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION")
.ok()
.or(config.session)
.unwrap_or_else(|| "default".to_string()),
headers: config.headers,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok()
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
.ok()
.or(config.executable_path),
cdp: config.cdp,
extensions,
profile: env::var("AGENT_BROWSER_PROFILE").ok()
.or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok()
.or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok()
.or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok()
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
.ok()
.or(config.proxy_bypass),
args: env::var("AGENT_BROWSER_ARGS").ok()
.or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok()
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
.ok()
.or(config.user_agent),
provider: env::var("AGENT_BROWSER_PROVIDER").ok()
.or(config.provider),
provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider),
ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|| config.ignore_https_errors.unwrap_or(false),
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|| config.allow_file_access.unwrap_or(false),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok()
.or(config.device),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|| config.auto_connect.unwrap_or(false),
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok()
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok()
.or(config.session_name),
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE")
|| config.annotate.unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok()
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") || config.annotate.unwrap_or(false),
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),
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|| config.content_boundaries.unwrap_or(false),
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT").ok()
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT")
.ok()
.and_then(|s| s.parse().ok())
.or(config.max_output),
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok()
.map(|s| s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect())
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS")
.ok()
.map(|s| {
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect()
})
.or(config.allowed_domains),
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY").ok()
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY")
.ok()
.or(config.action_policy),
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS")
.ok()
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.unwrap_or(false),
native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
@@ -352,22 +360,30 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--json" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.json = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--full" | "-f" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.full = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--headed" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.headed = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--debug" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.debug = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--session" => {
if let Some(s) = args.get(i + 1) {
@@ -452,13 +468,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--ignore-https-errors" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.ignore_https_errors = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--allow-file-access" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.allow_file_access = val;
flags.cli_allow_file_access = true;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--device" => {
if let Some(d) = args.get(i + 1) {
@@ -469,7 +489,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--auto-connect" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.auto_connect = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--session-name" => {
if let Some(s) = args.get(i + 1) {
@@ -481,7 +503,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
let (val, consumed) = parse_bool_arg(args, i);
flags.annotate = val;
flags.cli_annotate = true;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--color-scheme" => {
if let Some(s) = args.get(i + 1) {
@@ -499,7 +523,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--content-boundaries" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.content_boundaries = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--max-output" => {
if let Some(s) = args.get(i + 1) {
@@ -512,7 +538,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--allowed-domains" => {
if let Some(s) = args.get(i + 1) {
flags.allowed_domains = Some(
s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect()
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect(),
);
i += 1;
}
@@ -532,7 +561,16 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--confirm-interactive" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.confirm_interactive = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--native" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
if consumed {
i += 1;
}
}
"--config" => {
// Already handled by load_config(); skip the value
@@ -561,6 +599,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--native",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
@@ -835,7 +874,10 @@ mod tests {
assert_eq!(config.session.as_deref(), Some("test-session"));
assert_eq!(config.session_name.as_deref(), Some("my-app"));
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
assert_eq!(config.extensions, Some(vec!["/ext1".to_string(), "/ext2".to_string()]));
assert_eq!(
config.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
);
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
@@ -1144,7 +1186,11 @@ mod tests {
let merged = user.merge(project);
assert_eq!(
merged.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string(), "/ext3".to_string()])
Some(vec![
"/ext1".to_string(),
"/ext2".to_string(),
"/ext3".to_string()
])
);
}
+44 -8
View File
@@ -3,6 +3,7 @@ mod commands;
mod connection;
mod flags;
mod install;
mod native;
mod output;
mod validation;
@@ -20,7 +21,9 @@ use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, get_socket_dir, send_command, DaemonOptions};
use flags::{clean_args, parse_flags};
use install::run_install;
use output::{print_command_help, print_help, print_response_with_opts, print_version, OutputOptions};
use output::{
print_command_help, print_help, print_response_with_opts, print_version, OutputOptions,
};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
@@ -120,7 +123,10 @@ fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! {
}
Err(e) => {
if json_mode {
println!(r#"{{"success":false,"error":"Failed to run auth-cli: {}"}}"#, e);
println!(
r#"{{"success":false,"error":"Failed to run auth-cli: {}"}}"#,
e
);
} else {
eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e);
}
@@ -249,6 +255,14 @@ fn main() {
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
}
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(native::daemon::run_daemon(&session));
return;
}
let args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args);
let clean = clean_args(&args);
@@ -320,10 +334,17 @@ fn main() {
color::warning_indicator()
);
}
if cmd.get("passwordStdin").and_then(|v| v.as_bool()).unwrap_or(false) {
if cmd
.get("passwordStdin")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let mut pass = String::new();
if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() {
eprintln!("{} Failed to read password from stdin", color::error_indicator());
eprintln!(
"{} Failed to read password from stdin",
color::error_indicator()
);
exit(1);
}
let pass = pass.trim_end_matches('\n').trim_end_matches('\r');
@@ -339,7 +360,10 @@ fn main() {
// Handle local auth commands without starting the daemon.
// These don't need a browser, so we avoid sending passwords through the socket.
if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) {
if matches!(action, "auth_save" | "auth_list" | "auth_show" | "auth_delete") {
if matches!(
action,
"auth_save" | "auth_list" | "auth_show" | "auth_delete"
) {
run_auth_cli(&cmd, flags.json);
}
}
@@ -379,6 +403,7 @@ fn main() {
allowed_domains: flags.allowed_domains.as_deref(),
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
native: flags.native,
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
Ok(result) => result,
@@ -436,6 +461,7 @@ 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.native.then_some("--native"),
]
.into_iter()
.flatten()
@@ -788,10 +814,20 @@ fn main() {
// Handle interactive confirmation
if flags.confirm_interactive {
if let Some(data) = &resp.data {
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("unknown action");
if data
.get("confirmation_required")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let desc = data
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("unknown action");
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
let cid = data
.get("confirmation_id")
.and_then(|v| v.as_str())
.unwrap_or("");
eprintln!("[agent-browser] Action requires confirmation:");
eprintln!(" {}: {}", category, desc);
File diff suppressed because it is too large Load Diff
+315
View File
@@ -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
+417
View File
@@ -0,0 +1,417 @@
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());
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 ";
for line in reader.lines() {
if std::time::Instant::now() > deadline {
return Err("Timeout waiting for Chrome DevTools URL".to_string());
}
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());
}
}
Err("Chrome exited before providing DevTools URL".to_string())
}
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);
}
}
}
#[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));
}
}
}
}
}
#[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
}
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());
}
}
+163
View File
@@ -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
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod chrome;
pub mod client;
pub mod types;
+537
View File
@@ -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"));
}
+87
View File
@@ -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(())
}
+266
View File
@@ -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
}
+195
View File
@@ -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
+718
View File
@@ -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);
}
}
+707
View File
@@ -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)
}
}
}
}
+45
View File
@@ -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;
+399
View File
@@ -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);
}
}
+625
View File
@@ -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());
}
+216
View File
@@ -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");
}
}
+274
View File
@@ -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,
}),
))
}
+203
View File
@@ -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,
}))
}
+147
View File
@@ -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", &params, 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")
}
}
+736
View File
@@ -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"));
}
}
+607
View File
@@ -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");
}
}
+94
View File
@@ -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))
}
+385
View File
@@ -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);
}
}
+373
View File
@@ -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")
}
}
+201
View File
@@ -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);
}
}
+142
View File
@@ -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"));
}
}
+318
View File
@@ -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>()
)
})
}
+235
View File
@@ -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);
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod appium;
pub mod backend;
pub mod client;
pub mod ios;
pub mod safari;
pub mod types;
+80
View File
@@ -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());
}
}
}
}
+97
View File
@@ -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>,
}
+138 -47
View File
@@ -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 {
@@ -113,15 +122,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
return;
}
Some("diff_url") => {
if let Some(snap_data) =
obj.get("snapshot").and_then(|v| v.as_object())
{
if let Some(snap_data) = obj.get("snapshot").and_then(|v| v.as_object()) {
println!("{}", color::bold("Snapshot diff:"));
print_snapshot_diff(snap_data);
}
if let Some(ss_data) =
obj.get("screenshot").and_then(|v| v.as_object())
{
if let Some(ss_data) = obj.get("screenshot").and_then(|v| v.as_object()) {
println!("\n{}", color::bold("Screenshot diff:"));
print_screenshot_diff(ss_data);
}
@@ -269,7 +274,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();
@@ -404,11 +413,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
}
_ => {
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!(
"{} Recording started: {}",
color::success_indicator(),
path
);
println!("{} Recording started: {}", color::success_indicator(), path);
} else {
println!("{} Recording started", color::success_indicator());
}
@@ -591,7 +596,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or("");
let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0);
let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or("");
let encrypted = file.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
let encrypted = file
.get("encrypted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let size_str = if size > 1024 {
format!("{:.1}KB", size as f64 / 1024.0)
} else {
@@ -599,7 +607,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
};
let date_str = modified.split('T').next().unwrap_or(modified);
let enc_str = if encrypted { " [encrypted]" } else { "" };
println!(" {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)));
println!(
" {} {}",
filename,
color::dim(&format!("({}, {}){}", size_str, date_str, enc_str))
);
}
}
return;
@@ -609,13 +621,22 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) {
let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or("");
let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or("");
println!("{} Renamed {} -> {}", color::success_indicator(), old_name, new_name);
println!(
"{} Renamed {} -> {}",
color::success_indicator(),
old_name,
new_name
);
return;
}
// State clear
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) {
println!("{} Cleared {} state file(s)", color::success_indicator(), cleared);
println!(
"{} Cleared {} state file(s)",
color::success_indicator(),
cleared
);
return;
}
@@ -623,7 +644,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
if let Some(summary) = data.get("summary") {
let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0);
let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0);
let encrypted = data.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
let encrypted = data
.get("encrypted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let enc_str = if encrypted { " (encrypted)" } else { "" };
println!("State file summary{}:", enc_str);
println!(" Cookies: {}", cookies);
@@ -633,7 +657,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// State clean
if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) {
println!("{} Cleaned {} old state file(s)", color::success_indicator(), cleaned);
println!(
"{} Cleaned {} old state file(s)",
color::success_indicator(),
cleaned
);
return;
}
@@ -652,7 +680,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;
@@ -662,8 +695,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);
@@ -678,47 +717,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;
}
@@ -2362,6 +2448,7 @@ Options:
--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
@@ -2417,6 +2504,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
@@ -2494,10 +2582,7 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
.get("mismatchPercentage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let is_match = data
.get("match")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_match = data.get("match").and_then(|v| v.as_bool()).unwrap_or(false);
let dim_mismatch = data
.get("dimensionMismatch")
.and_then(|v| v.as_bool())
@@ -2508,7 +2593,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
color::error_indicator()
);
} else if is_match {
println!("{} Images match (0% difference)", color::success_indicator());
println!(
"{} Images match (0% difference)",
color::success_indicator()
);
} else {
println!(
"{} {:.2}% pixels differ",
@@ -2519,7 +2607,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) {
println!(" Diff image: {}", color::green(diff_path));
}
let total = data.get("totalPixels").and_then(|v| v.as_i64()).unwrap_or(0);
let total = data
.get("totalPixels")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let different = data
.get("differentPixels")
.and_then(|v| v.as_i64())
+4 -1
View File
@@ -1,6 +1,9 @@
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
pub fn is_valid_session_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
!name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
/// Generate error message for invalid session name
+3 -1
View File
@@ -80,6 +80,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
<tr><td><code>actionPolicy</code></td><td><code>--action-policy</code></td><td>string</td></tr>
<tr><td><code>confirmActions</code></td><td><code>--confirm-actions</code></td><td>string</td></tr>
<tr><td><code>confirmInteractive</code></td><td><code>--confirm-interactive</code></td><td>boolean</td></tr>
<tr><td><code>native</code></td><td><code>--native</code></td><td>boolean (experimental)</td></tr>
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
</tbody>
</table>
@@ -149,7 +150,7 @@ agent-browser --headed open example.com # same as --headed true
agent-browser --headed true open example.com # explicit
```
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--content-boundaries`, `--confirm-interactive`.
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--content-boundaries`, `--confirm-interactive`, `--native`.
## Extensions Merging
@@ -184,6 +185,7 @@ These environment variables configure additional daemon and runtime behavior:
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts (auto-denies if stdin is not a TTY).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_NATIVE</code></td><td>Use the experimental native Rust daemon instead of Node.js/Playwright.</td><td>(disabled)</td></tr>
</tbody>
</table>
+85
View File
@@ -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
```
+1
View File
@@ -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" },
],
},
{
+1
View File
@@ -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",
};
+15
View File
@@ -485,6 +485,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 |