feat: add network har start/stop command for HAR 1.2 export (#874)

Expose HAR recording as a CLI subcommand under the existing `network`
command so users can capture and export network traffic without a
separate tool or opening the browser twice.

- Parse `network har start` and `network har stop [path]` in commands.rs
- Enrich HarEntry with request/response headers, timestamps, status text,
  resource type, HTTP version, and body sizes from CDP events
- Produce HAR 1.2 output with creator/browser metadata, query strings,
  and proper header arrays compatible with Chrome DevTools HAR viewer
- Auto-generate output path under ~/.agent-browser/tmp/har/ when omitted
- Add har_stop to skip_launch list so export works without a live browser
- Update help text, README, docs site, SKILL.md, and security policy docs
- Add unit tests for parsing, HAR entry serialization, and stop behavior
This commit is contained in:
Chris Yang
2026-03-17 09:02:39 -05:00
committed by GitHub
parent 663e10355a
commit 8dd012f4fd
9 changed files with 536 additions and 34 deletions
+2
View File
@@ -260,6 +260,8 @@ agent-browser network route <url> --body <json> # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
agent-browser network har start # Start HAR recording
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
```
### Tabs & Windows
+53
View File
@@ -60,6 +60,7 @@ dependencies = [
"serde_json",
"sha2",
"similar",
"time",
"tokio",
"tokio-tungstenite",
"url",
@@ -386,6 +387,15 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1174,6 +1184,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -1296,6 +1312,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1924,6 +1946,37 @@ dependencies = [
"zune-jpeg 0.4.21",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.2"
+1
View File
@@ -28,6 +28,7 @@ aes-gcm = "0.10"
async-trait = "0.1"
similar = "2"
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
time = { version = "0.3", features = ["formatting"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+52 -2
View File
@@ -2042,8 +2042,9 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
}
/// Parse network interception, request inspection, and HAR recording commands.
fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["route", "unroute", "requests"];
const VALID: &[&str] = &["route", "unroute", "requests", "har"];
match rest.first().copied() {
Some("route") => {
@@ -2073,13 +2074,34 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
Ok(cmd)
}
Some("har") => {
const HAR_VALID: &[&str] = &["start", "stop"];
match rest.get(1).copied() {
Some("start") => Ok(json!({ "id": id, "action": "har_start" })),
Some("stop") => {
let mut cmd = json!({ "id": id, "action": "har_stop" });
if let Some(path) = rest.get(2) {
cmd["path"] = json!(path);
}
Ok(cmd)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: HAR_VALID,
}),
None => Err(ParseError::MissingArguments {
context: "network har".to_string(),
usage: "network har <start|stop> [path]",
}),
}
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "network".to_string(),
usage: "network <route|unroute|requests> [args...]",
usage: "network <route|unroute|requests|har> [args...]",
}),
}
}
@@ -2659,6 +2681,34 @@ mod tests {
assert_eq!(cmd["action"], "tab_close");
}
// === Network ===
#[test]
fn test_network_har_start() {
let cmd = parse_command(&args("network har start"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "har_start");
}
#[test]
fn test_network_har_stop_with_path() {
let cmd = parse_command(&args("network har stop ./capture.har"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "har_stop");
assert_eq!(cmd["path"], "./capture.har");
}
#[test]
fn test_network_har_stop_without_path() {
let cmd = parse_command(&args("network har stop"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "har_stop");
assert!(cmd.get("path").is_none());
}
#[test]
fn test_network_har_requires_subcommand() {
let result = parse_command(&args("network har"), &default_flags());
assert!(matches!(result, Err(ParseError::MissingArguments { .. })));
}
// === Screenshot ===
#[test]
+406 -28
View File
@@ -1,8 +1,10 @@
use serde_json::{json, Value};
use std::env;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use tokio::sync::{broadcast, oneshot, RwLock};
use super::auth;
@@ -38,12 +40,21 @@ pub struct PendingConfirmation {
pub cmd: Value,
}
/// Captured request/response metadata used to export HAR 1.2 files.
pub struct HarEntry {
pub method: String,
pub url: String,
pub status: Option<i64>,
pub status_text: Option<String>,
pub mime_type: Option<String>,
pub request_id: String,
pub request_headers: Value,
pub request_body_size: i64,
pub response_headers: Value,
pub started_date_time: String,
pub resource_type: String,
pub http_version: Option<String>,
pub response_body_size: Option<i64>,
}
pub struct RouteEntry {
@@ -338,12 +349,36 @@ impl DaemonState {
.unwrap_or("")
.to_string();
if self.har_recording {
let headers =
request.get("headers").cloned().unwrap_or(json!({}));
let request_body_size = request
.get("postData")
.and_then(|v| v.as_str())
.map(|body| body.len() as i64)
.unwrap_or(0);
let resource_type = event
.params
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("Other")
.to_string();
let started_date_time = har_started_date_time(
event.params.get("wallTime").and_then(|v| v.as_f64()),
);
self.har_entries.push(HarEntry {
method: method.clone(),
url: url.clone(),
status: None,
status_text: None,
mime_type: None,
request_id,
request_headers: headers,
request_body_size,
response_headers: json!({}),
started_date_time,
resource_type,
http_version: None,
response_body_size: None,
});
}
if self.request_tracking {
@@ -381,6 +416,19 @@ impl DaemonState {
.get("mimeType")
.and_then(|v| v.as_str())
.map(String::from);
let status_text = response
.get("statusText")
.and_then(|v| v.as_str())
.map(String::from);
let headers = response.get("headers").cloned().unwrap_or(json!({}));
let http_version = response
.get("protocol")
.and_then(|v| v.as_str())
.map(String::from);
let response_body_size = response
.get("encodedDataLength")
.and_then(|v| v.as_f64())
.map(|size| size.max(0.0) as i64);
if let Some(entry) = self
.har_entries
.iter_mut()
@@ -388,7 +436,35 @@ impl DaemonState {
.find(|e| e.request_id == request_id)
{
entry.status = status;
entry.status_text = status_text;
entry.mime_type = mime_type;
entry.response_headers = headers;
entry.http_version = http_version;
if response_body_size.is_some() {
entry.response_body_size = response_body_size;
}
}
}
}
"Network.loadingFinished" if self.har_recording => {
let request_id = event
.params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("");
let encoded_data_length = event
.params
.get("encodedDataLength")
.and_then(|v| v.as_f64())
.map(|size| size.max(0.0) as i64);
if let Some(entry) = self
.har_entries
.iter_mut()
.rev()
.find(|e| e.request_id == request_id)
{
if encoded_data_length.is_some() {
entry.response_body_size = encoded_data_length;
}
}
}
@@ -571,6 +647,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
action,
"" | "launch"
| "close"
| "har_stop"
| "credentials_set"
| "credentials_get"
| "credentials_delete"
@@ -4402,6 +4479,7 @@ async fn handle_video_stop(state: &mut DaemonState) -> Result<Value, String> {
recording::recording_stop(&mut state.recording_state)
}
/// Begin capturing network traffic for a later HAR export.
async fn handle_har_start(state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
@@ -4413,48 +4491,214 @@ async fn handle_har_start(state: &mut DaemonState) -> Result<Value, String> {
Ok(json!({ "started": true }))
}
/// Stop HAR recording and write the captured requests to disk.
async fn handle_har_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let path = cmd
.get("path")
.and_then(|v| v.as_str())
.ok_or("Missing 'path' parameter")?;
let path = har_output_path(cmd.get("path").and_then(|v| v.as_str()));
state.har_recording = false;
let entries: Vec<Value> = state
.har_entries
.drain(..)
.map(|e| {
json!({
"request": {
"method": e.method,
"url": e.url,
},
"response": {
"status": e.status.unwrap_or(0),
"content": {
"mimeType": e.mime_type.unwrap_or_default(),
}
}
})
})
.collect();
let entries: Vec<Value> = state.har_entries.drain(..).map(har_entry_json).collect();
let request_count = entries.len();
let browser = har_browser_metadata(state).await;
let har = json!({
"log": {
"version": "1.2",
"entries": entries
}
let mut log = json!({
"version": "1.2",
"creator": {
"name": "agent-browser",
"version": env!("CARGO_PKG_VERSION")
},
"entries": entries
});
if let Some(browser) = browser {
log["browser"] = browser;
}
let har = json!({ "log": log });
let har_str = serde_json::to_string_pretty(&har)
.map_err(|e| format!("Failed to serialize HAR: {}", e))?;
std::fs::write(path, har_str).map_err(|e| format!("Failed to write HAR: {}", e))?;
std::fs::write(&path, har_str).map_err(|e| format!("Failed to write HAR: {}", e))?;
Ok(json!({ "path": path, "requestCount": request_count }))
}
fn har_entry_json(entry: HarEntry) -> Value {
let query_string = har_query_string(&entry.url);
let request_headers = har_headers(&entry.request_headers);
let response_headers = har_headers(&entry.response_headers);
let http_version = entry.http_version.unwrap_or_default();
let body_size = entry.response_body_size.unwrap_or(-1);
let content_size = entry.response_body_size.unwrap_or(0);
json!({
"startedDateTime": entry.started_date_time,
"time": 0,
"request": {
"method": entry.method,
"url": entry.url,
"httpVersion": http_version.clone(),
"cookies": [],
"headers": request_headers,
"queryString": query_string,
"headersSize": -1,
"bodySize": entry.request_body_size,
},
"response": {
"status": entry.status.unwrap_or(0),
"statusText": entry.status_text.unwrap_or_default(),
"httpVersion": http_version,
"cookies": [],
"headers": response_headers,
"content": {
"size": content_size,
"mimeType": entry.mime_type.unwrap_or_default(),
},
"redirectURL": har_redirect_url(&entry.response_headers),
"headersSize": -1,
"bodySize": body_size,
},
"cache": {},
"timings": {
"blocked": -1,
"dns": -1,
"connect": -1,
"send": -1,
"wait": -1,
"receive": -1,
"ssl": -1,
},
"_resourceType": entry.resource_type,
})
}
/// Convert the raw CDP header object into HAR header items.
fn har_headers(headers: &Value) -> Vec<Value> {
let Some(obj) = headers.as_object() else {
return Vec::new();
};
let mut entries: Vec<(&String, &Value)> = obj.iter().collect();
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
entries
.into_iter()
.map(|(name, value)| {
json!({
"name": name,
"value": har_header_value(value),
})
})
.collect()
}
fn har_header_value(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Array(values) => values
.iter()
.map(har_header_value)
.collect::<Vec<_>>()
.join(", "),
Value::Object(_) => value.to_string(),
}
}
fn har_query_string(raw_url: &str) -> Vec<Value> {
let Ok(url) = url::Url::parse(raw_url) else {
return Vec::new();
};
url.query_pairs()
.map(|(name, value)| {
json!({
"name": name.into_owned(),
"value": value.into_owned(),
})
})
.collect()
}
fn har_redirect_url(headers: &Value) -> String {
har_header_lookup(headers, "location").unwrap_or_default()
}
fn har_header_lookup(headers: &Value, expected_name: &str) -> Option<String> {
headers.as_object().and_then(|obj| {
obj.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(expected_name))
.map(|(_, value)| har_header_value(value))
})
}
fn har_output_path(explicit_path: Option<&str>) -> String {
match explicit_path {
Some(path) => path.to_string(),
None => {
let dir = get_har_dir();
let _ = std::fs::create_dir_all(&dir);
dir.join(format!("har-{}.har", unix_timestamp_millis()))
.to_string_lossy()
.to_string()
}
}
}
fn get_har_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("har")
} else {
std::env::temp_dir().join("agent-browser").join("har")
}
}
fn unix_timestamp_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn har_started_date_time(wall_time: Option<f64>) -> String {
wall_time
.and_then(offset_date_time_from_epoch_seconds)
.unwrap_or_else(OffsetDateTime::now_utc)
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
fn offset_date_time_from_epoch_seconds(seconds: f64) -> Option<OffsetDateTime> {
if !seconds.is_finite() || seconds < 0.0 {
return None;
}
let nanos = (seconds * 1_000_000_000.0).round() as i128;
OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()
}
async fn har_browser_metadata(state: &DaemonState) -> Option<Value> {
let mgr = state.browser.as_ref()?;
if !mgr.is_connection_alive().await {
return None;
}
let version = mgr
.client
.send_command_no_params("Browser.getVersion", None)
.await
.ok()?;
browser_metadata_from_version(&version)
}
fn browser_metadata_from_version(version: &Value) -> Option<Value> {
let product = version.get("product").and_then(|v| v.as_str())?;
let (name, browser_version) = product.split_once('/').unwrap_or((product, ""));
Some(json!({
"name": name,
"version": browser_version,
}))
}
// ---------------------------------------------------------------------------
// Fetch interception resolver (routes + domain filter)
// ---------------------------------------------------------------------------
@@ -5340,6 +5584,7 @@ fn error_response(id: &str, error: &str) -> Value {
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
use std::fs;
#[test]
fn test_success_response_structure() {
@@ -5388,6 +5633,139 @@ mod tests {
);
}
#[test]
fn test_har_entry_json_enriches_request_and_response() {
let entry = HarEntry {
method: "POST".to_string(),
url: "https://example.com/api?foo=bar&baz=qux".to_string(),
status: Some(201),
status_text: Some("Created".to_string()),
mime_type: Some("application/json".to_string()),
request_id: "req-1".to_string(),
request_headers: json!({
"Accept": "application/json",
"X-Trace": ["a", "b"],
}),
request_body_size: 17,
response_headers: json!({
"Content-Type": "application/json",
"Location": "https://example.com/api/1",
}),
started_date_time: "2026-03-15T12:00:00Z".to_string(),
resource_type: "XHR".to_string(),
http_version: Some("h2".to_string()),
response_body_size: Some(42),
};
let har = har_entry_json(entry);
assert_eq!(har["startedDateTime"], "2026-03-15T12:00:00Z");
assert_eq!(har["request"]["method"], "POST");
assert_eq!(har["request"]["httpVersion"], "h2");
assert_eq!(har["request"]["queryString"][0]["name"], "foo");
assert_eq!(har["request"]["queryString"][0]["value"], "bar");
assert_eq!(har["request"]["bodySize"], 17);
assert_eq!(har["response"]["status"], 201);
assert_eq!(har["response"]["statusText"], "Created");
assert_eq!(har["response"]["content"]["mimeType"], "application/json");
assert_eq!(har["response"]["content"]["size"], 42);
assert_eq!(har["response"]["redirectURL"], "https://example.com/api/1");
assert_eq!(har["_resourceType"], "XHR");
}
#[test]
fn test_har_started_date_time_uses_rfc3339() {
assert_eq!(har_started_date_time(Some(0.0)), "1970-01-01T00:00:00Z");
}
#[tokio::test]
async fn test_handle_har_stop_without_path_uses_default_location() {
let mut state = DaemonState::new();
state.har_recording = true;
state.har_entries.push(HarEntry {
method: "GET".to_string(),
url: "https://example.com/".to_string(),
status: Some(200),
status_text: Some("OK".to_string()),
mime_type: Some("text/html".to_string()),
request_id: "req-2".to_string(),
request_headers: json!({ "Accept": "text/html" }),
request_body_size: 0,
response_headers: json!({ "Content-Type": "text/html" }),
started_date_time: "2026-03-15T12:00:00Z".to_string(),
resource_type: "Document".to_string(),
http_version: Some("h2".to_string()),
response_body_size: Some(128),
});
let result = handle_har_stop(&json!({ "action": "har_stop" }), &mut state)
.await
.unwrap();
let path = result["path"].as_str().unwrap();
assert!(path.ends_with(".har"));
assert!(std::path::Path::new(path).starts_with(get_har_dir()));
assert_eq!(result["requestCount"], 1);
assert!(!state.har_recording);
assert!(state.har_entries.is_empty());
let har: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(har["log"]["version"], "1.2");
assert_eq!(har["log"]["creator"]["name"], "agent-browser");
assert!(har["log"].get("browser").is_none());
assert_eq!(har["log"]["entries"][0]["response"]["content"]["size"], 128);
let _ = fs::remove_file(path);
}
#[tokio::test]
async fn test_execute_har_stop_skips_browser_auto_launch() {
let path = std::env::temp_dir().join(format!(
"agent-browser-har-stop-{}.har",
unix_timestamp_millis()
));
let mut state = DaemonState::new();
state.har_entries.push(HarEntry {
method: "GET".to_string(),
url: "https://example.com/".to_string(),
status: Some(200),
status_text: Some("OK".to_string()),
mime_type: Some("text/html".to_string()),
request_id: "req-3".to_string(),
request_headers: json!({}),
request_body_size: 0,
response_headers: json!({}),
started_date_time: "2026-03-15T12:00:00Z".to_string(),
resource_type: "Document".to_string(),
http_version: Some("h2".to_string()),
response_body_size: Some(64),
});
let result = execute_command(
&json!({
"action": "har_stop",
"id": "har-stop-1",
"path": path.to_string_lossy().to_string()
}),
&mut state,
)
.await;
assert_eq!(result["success"], true);
assert_eq!(result["data"]["requestCount"], 1);
let _ = fs::remove_file(path);
}
#[test]
fn test_browser_metadata_from_version_parses_product() {
let metadata = browser_metadata_from_version(&json!({
"product": "HeadlessChrome/123.0.6312.0"
}))
.unwrap();
assert_eq!(metadata["name"], "HeadlessChrome");
assert_eq!(metadata["version"], "123.0.6312.0");
}
#[tokio::test]
async fn test_execute_unknown_command() {
let mut state = DaemonState::new();
+13 -3
View File
@@ -456,13 +456,16 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
println!("{} {}", color::success_indicator(), label);
return;
}
// Recording start (has "started" field)
// Started actions (profiling, HAR, recording)
if let Some(started) = data.get("started").and_then(|v| v.as_bool()) {
if started {
match action {
Some("profiler_start") => {
println!("{} Profiling started", color::success_indicator());
}
Some("har_start") => {
println!("{} HAR recording started", color::success_indicator());
}
_ => {
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!("{} Recording started: {}", color::success_indicator(), path);
@@ -591,9 +594,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0)
),
"har_stop" => println!(
"{} HAR saved to {}",
"{} HAR saved to {} ({} requests)",
color::success_indicator(),
color::green(path)
color::green(path),
data.get("requestCount")
.and_then(|c| c.as_u64())
.unwrap_or(0)
),
"download" | "waitfordownload" => println!(
"{} Download saved to {}",
@@ -1721,6 +1727,7 @@ Subcommands:
requests [options] List captured requests
--clear Clear request log
--filter <pattern> Filter by URL pattern
har <start|stop> [path] Record and export a HAR file
Global Options:
--json Output as JSON
@@ -1733,6 +1740,8 @@ Examples:
agent-browser network requests
agent-browser network requests --filter "api"
agent-browser network requests --clear
agent-browser network har start
agent-browser network har stop ./capture.har
"##
}
@@ -2516,6 +2525,7 @@ Network: agent-browser network <action>
route <url> [--abort|--body <json>]
unroute [url]
requests [--clear] [--filter <pattern>]
har <start|stop> [path]
Storage:
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
+2
View File
@@ -177,6 +177,8 @@ agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --clear # Clear request log
agent-browser network requests --filter <pat> # Filter by URL pattern
agent-browser network har start # Start HAR recording
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
```
## Tabs & frames
+1 -1
View File
@@ -169,7 +169,7 @@ Example policy (restrictive):
<tr><td><code>wait</code></td><td>wait, waitforurl, waitforloadstate, waitforfunction</td></tr>
<tr><td><code>get</code></td><td>get text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.</td></tr>
<tr><td><code>interact</code></td><td>hover, focus, drag, press, keydown, keyup, mousemove, dispatch</td></tr>
<tr><td><code>network</code></td><td>network route/unroute, requests</td></tr>
<tr><td><code>network</code></td><td>network route/unroute, requests, har start/stop</td></tr>
<tr><td><code>state</code></td><td>state save/load, cookies set, storage set</td></tr>
</tbody>
</table>
+6
View File
@@ -147,6 +147,12 @@ agent-browser download @e1 ./file.pdf # Click element to trigger downlo
agent-browser wait --download ./output.zip # Wait for any download to complete
agent-browser --download-path ./downloads open <url> # Set default download directory
# Network
agent-browser network requests # Inspect tracked requests
agent-browser network route "**/api/*" --abort # Block matching requests
agent-browser network har start # Start HAR recording
agent-browser network har stop ./capture.har # Stop and save HAR file
# Viewport & Device Emulation
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
agent-browser set viewport 1920 1080 2 # 2x retina (same CSS size, higher res screenshots)