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
+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]