Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ Detailed documentation for individual commands:

- [read-page](wiki/read-page.md) — page content extraction as markdown

## Agent Skill

`skill/chrome-devtools/SKILL.md` is the **source of truth** for the agent-facing
skill/documentation — it's what teaches an agent (e.g. Claude Code, opencode) how
to use this CLI (targeting, standard patterns, gotchas, failure handling, etc.).
It gets installed/copied into an agent's skills directory (e.g.
`~/.config/opencode/skills/chrome-devtools/SKILL.md`); that installed copy is a
**deployed artifact**, not the source — always edit `skill/chrome-devtools/SKILL.md`
in this repo, then re-sync/reinstall it, rather than editing the installed copy
directly. `skill/chrome-devtools/CUSTOM_SCRIPTING.md` documents `run-script` and
`adapter` in more depth.

## Key Concepts

### Daemon Architecture
Expand All @@ -54,6 +66,17 @@ A background daemon (`/tmp/chrome-devtools-daemon.sock`) keeps a persistent CDP
WebSocket connection. First CLI invocation spawns it; subsequent commands reuse
it. 5-minute idle timeout.

`CdpClient::connect` (`cdp.rs`) bounds the WebSocket handshake with a timeout
(`CHROME_CONNECT_TIMEOUT_SECS`, default 10s). Without it, a pending Chrome
remote-debugging consent dialog would hang the handshake indefinitely — and
since the daemon binds its socket *before* connecting to Chrome (see comments
in `daemon.rs`), the CLI's `wait_for_daemon()` would succeed immediately while
the actual request silently hung forever waiting on the daemon's response, with
no error and no way for a caller (especially an unattended agent) to tell what
was wrong. The timeout error message is written to be agent-actionable: retry
at most once, then stop and ask a human rather than looping or calling
`kill-daemon`.

### Page Targeting

Every page gets a deterministic friendly name (e.g. `warm-squid`) derived from
Expand All @@ -77,6 +100,12 @@ LLM-friendly) produce structured output. Mutually exclusive.
before any Chrome connection or daemon spawn. `inspect-heapsnapshot-node` parses
a local `.heapsnapshot` file purely offline.

`kill-daemon` drops the daemon's already-approved Chrome connection, so it's
guarded (`kill_daemon_decision` in `lib.rs`): interactive (TTY) callers get a
`[y/N]` confirmation prompt; non-interactive callers (agents, scripts) are
refused outright unless `--force` is passed. It must never be used as a
"retry" step for connection failures — see the timeout note above.

### Path Resolution

The daemon retains its startup CWD, so the CLI resolves all relative file-path
Expand Down
43 changes: 41 additions & 2 deletions skill/chrome-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,33 @@ chrome-devtools --target <name> adapter <file-path> <function-name> [--arg key=v

### Daemon
```bash
chrome-devtools kill-daemon # stop the background daemon process
```
chrome-devtools kill-daemon # refuses when run non-interactively (agents) unless --force
chrome-devtools kill-daemon --force # kills unconditionally
```

## Failure Handling: "Failed to connect to Chrome" / a command hangs

Chrome's remote-debugging connection requires a one-time **human approval dialog**
in Chrome. If a command hangs or fails with a connection/timeout error, the most
likely cause is that this dialog is open and waiting for the human — not a bug
you can fix by retrying.

**If a command hangs for a long time or errors with "Failed to connect to Chrome"
or "Timed out ... connecting to Chrome":**
1. Retry **at most once** (the human may have already approved it just now).
2. If it fails again, **STOP.** Do not keep retrying — the human is very likely
away from the keyboard and no amount of retrying will approve the dialog for
them.
3. Tell the user directly that Chrome is waiting for them to approve the
remote-debugging connection dialog, and wait for their response.

**Never run `kill-daemon` as a way to "fix" a connection problem.** It does not
help — it destroys the daemon's already-approved connection (if one exists) and
guarantees the *next* attempt needs a fresh human approval, making things worse.
For this reason, `kill-daemon` refuses to run non-interactively without
`--force`. As an agent, only pass `--force` if the user has explicitly asked you
to kill the daemon for some other reason (e.g. it's stuck on unrelated JS
execution) — never as a reaction to a connection failure.

## Critical Gotchas

Expand Down Expand Up @@ -459,6 +484,20 @@ chrome-devtools --target warm-squid console
### ✓ CORRECT: Each drain is a fresh window
Run `console` / `network` right after the action that produces events, OR use `--duration` to collect for a window of time.

### ✗ WRONG: Retrying in a loop or running `kill-daemon` on connection failure
```bash
chrome-devtools list-pages # hangs / fails: "Failed to connect to Chrome"
chrome-devtools kill-daemon --force # ❌ WRONG: destroys the approved connection, makes it worse
chrome-devtools list-pages # retry again, and again... ❌ WRONG: human is likely away
```

### ✓ CORRECT: Retry once, then stop and ask the human
```bash
chrome-devtools list-pages # hangs / fails: "Failed to connect to Chrome"
chrome-devtools list-pages # ✓ one retry, in case the human just approved it
# still failing → STOP retrying, tell the user Chrome needs approval, and wait
```

## Output Format Summary

| Flag | Description |
Expand Down
28 changes: 27 additions & 1 deletion src/cdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,37 @@ fn stash_tab_emulation(
}
}

/// Read the Chrome WebSocket connect timeout from `CHROME_CONNECT_TIMEOUT_SECS`,
/// defaulting to 10.
fn connect_timeout() -> std::time::Duration {
std::env::var("CHROME_CONNECT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.unwrap_or(std::time::Duration::from_secs(10))
}

impl CdpClient {
/// Connect to Chrome via WebSocket and return a CDP client.
///
/// Bounded by a timeout: without it, a pending Chrome remote-debugging
/// consent dialog leaves the WebSocket handshake hanging indefinitely,
/// which (via the daemon) makes every command appear to hang forever
/// with no diagnostic.
pub async fn connect(ws_url: &str) -> Result<Self> {
let (ws, _) = connect_async(ws_url)
let timeout_dur = connect_timeout();
let (ws, _) = tokio::time::timeout(timeout_dur, connect_async(ws_url))
.await
.map_err(|_| {
anyhow!(
"Timed out after {}s connecting to Chrome at {ws_url}. Chrome may be \
waiting for a human to approve the remote-debugging connection dialog. \
If you are an automated agent: do not retry in a loop and do not run \
kill-daemon (it will not fix this and will require a fresh approval) — \
stop and ask the human to check Chrome, then retry once.",
timeout_dur.as_secs()
)
})?
.map_err(|e| anyhow!("Failed to connect to Chrome at {ws_url}: {e}"))?;
let (write, read) = ws.split();
Ok(Self {
Expand Down
98 changes: 72 additions & 26 deletions src/commands/evaluate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ pub async fn run_script(
}
}

let nav_url = if interpolated_url.starts_with("http://") || interpolated_url.starts_with("https://") {
let nav_url = if interpolated_url.starts_with("http://")
|| interpolated_url.starts_with("https://")
{
interpolated_url.clone()
} else if is_local_host(&interpolated_url) {
format!("http://{}", interpolated_url)
Expand All @@ -258,7 +260,10 @@ pub async fn run_script(

let current_url = client.current_url(session_id).await?;
if current_url.trim_end_matches('/') != nav_url.trim_end_matches('/') {
eprintln!("[script] Current URL '{}' does not match target URL '{}'. Auto-navigating...", current_url, nav_url);
eprintln!(
"[script] Current URL '{}' does not match target URL '{}'. Auto-navigating...",
current_url, nav_url
);

crate::commands::navigate::navigate(
client,
Expand Down Expand Up @@ -327,7 +332,10 @@ fn parse_adapter_domains(content: &str) -> Vec<String> {
if trimmed.starts_with("import ") || trimmed.starts_with("import(") {
continue;
}
if matches!(trimmed, "\"use strict\";" | "'use strict';" | "\"use strict\"" | "'use strict'") {
if matches!(
trimmed,
"\"use strict\";" | "'use strict';" | "\"use strict\"" | "'use strict'"
) {
continue;
}
break;
Expand Down Expand Up @@ -516,26 +524,29 @@ pub async fn run_adapter(
let domains = parse_adapter_domains(&script_content);
if !domains.is_empty() {
let current_url = client.current_url(session_id).await?;
let matched = domains.iter().any(|domain| url_matches_domain(&current_url, domain));
let matched = domains
.iter()
.any(|domain| url_matches_domain(&current_url, domain));

if !matched {
let target_domain = &domains[0];
// Preserve the host exactly as declared in `@domain`; only supply a
// scheme when one is missing. Forcing a `www.` subdomain breaks apex
// hosts and adapters that target an existing subdomain
// (e.g. `creator.xiaohongshu.com`).
let target_url = if target_domain.starts_with("http://") || target_domain.starts_with("https://") {
// An explicit scheme always wins, so authors can force http/https
// by writing it in `@domain` (e.g. `@domain http://localhost:3000`).
target_domain.clone()
} else if is_local_host(target_domain) {
// Local dev servers generally speak http, not https.
format!("http://{}", target_domain)
} else {
format!("https://{}", target_domain)
};
let target_url =
if target_domain.starts_with("http://") || target_domain.starts_with("https://") {
// An explicit scheme always wins, so authors can force http/https
// by writing it in `@domain` (e.g. `@domain http://localhost:3000`).
target_domain.clone()
} else if is_local_host(target_domain) {
// Local dev servers generally speak http, not https.
format!("http://{}", target_domain)
} else {
format!("https://{}", target_domain)
};
eprintln!("[adapter] Current URL '{}' does not match adapter domains {:?}. Auto-navigating to '{}'...", current_url, domains, target_url);

crate::commands::navigate::navigate(
client,
session_id,
Expand All @@ -549,7 +560,9 @@ pub async fn run_adapter(
.await?;

let post_nav_url = client.current_url(session_id).await?;
let post_matched = domains.iter().any(|domain| url_matches_domain(&post_nav_url, domain));
let post_matched = domains
.iter()
.any(|domain| url_matches_domain(&post_nav_url, domain));
if !post_matched {
anyhow::bail!(
"Auto-navigation to '{}' resulted in URL '{}' which does not match adapter domains {:?}",
Expand Down Expand Up @@ -720,7 +733,10 @@ mod tests {
// (only declarations and bare re-exports are handled).
let src = "export * from './x';\nexport const ok = 1;\nexport constants = 2;";
let out = strip_export_keywords(src);
assert_eq!(out, "export * from './x';\nconst ok = 1;\nexport constants = 2;");
assert_eq!(
out,
"export * from './x';\nconst ok = 1;\nexport constants = 2;"
);
}

#[test]
Expand Down Expand Up @@ -748,9 +764,15 @@ mod tests {

#[test]
fn test_normalize_host() {
assert_eq!(normalize_host("http://user:pass@example.com/some/path"), "example.com");
assert_eq!(
normalize_host("http://user:pass@example.com/some/path"),
"example.com"
);
assert_eq!(normalize_host("http://user:pass@[::1]:8080"), "[::1]");
assert_eq!(normalize_host("http://user:pass@127.0.0.1:8080"), "127.0.0.1");
assert_eq!(
normalize_host("http://user:pass@127.0.0.1:8080"),
"127.0.0.1"
);
assert_eq!(normalize_host("https://foo:bar@localhost"), "localhost");
assert_eq!(normalize_host("example.com"), "example.com");
assert_eq!(normalize_host("http://example.com:3000/"), "example.com");
Expand All @@ -772,19 +794,37 @@ mod tests {

#[test]
fn test_url_matches_domain() {
assert!(url_matches_domain("https://www.xiaohongshu.com/explore", "xiaohongshu.com"));
assert!(url_matches_domain("http://creator.xiaohongshu.com", "creator.xiaohongshu.com"));
assert!(url_matches_domain("https://xiaohongshu.com:8080/path", "xiaohongshu.com"));
assert!(url_matches_domain(
"https://www.xiaohongshu.com/explore",
"xiaohongshu.com"
));
assert!(url_matches_domain(
"http://creator.xiaohongshu.com",
"creator.xiaohongshu.com"
));
assert!(url_matches_domain(
"https://xiaohongshu.com:8080/path",
"xiaohongshu.com"
));
assert!(url_matches_domain("http://[::1]:3000", "[::1]"));
assert!(!url_matches_domain("https://google.com", "xiaohongshu.com"));
}

#[test]
fn test_url_matches_domain_normalizes_domain() {
// `@domain` written with a scheme and/or path still matches the host.
assert!(url_matches_domain("https://www.example.com/page", "https://example.com"));
assert!(url_matches_domain("https://example.com/explore", "example.com/path"));
assert!(url_matches_domain("https://example.com", "http://example.com:443/"));
assert!(url_matches_domain(
"https://www.example.com/page",
"https://example.com"
));
assert!(url_matches_domain(
"https://example.com/explore",
"example.com/path"
));
assert!(url_matches_domain(
"https://example.com",
"http://example.com:443/"
));
assert!(!url_matches_domain("https://example.com", ""));
}

Expand All @@ -800,7 +840,13 @@ mod tests {
let ctx = build_ctx_object(r#"{"query":"hi"}"#);
assert!(ctx.starts_with("const ctx = {"));
assert!(ctx.contains(r#"args: {"query":"hi"}"#));
for helper in ["wait:", "waitForText:", "waitForSelector:", "click:", "fill:"] {
for helper in [
"wait:",
"waitForText:",
"waitForSelector:",
"click:",
"fill:",
] {
assert!(ctx.contains(helper), "missing helper: {helper}");
}
// fill must special-case checkable inputs instead of setting `value`.
Expand Down
30 changes: 25 additions & 5 deletions src/commands/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub fn known_args(cmd: &str) -> &'static [&'static str] {
"read-page" => &["output"],
"take-heapsnapshot" => &["output"],
"inspect-heapsnapshot-node" => &["file_path", "node_id"],
"compare-heapsnapshots" => &["base", "current", "class_index"],
"emulate" => &[
"viewport",
"device_scale_factor",
Expand All @@ -72,9 +73,22 @@ pub fn known_args(cmd: &str) -> &'static [&'static str] {
"console" => &["duration", "type"],
"network" => &["duration", "type"],
"sw-logs" => &["duration", "extension_id"],
"run-script" => &["file_path", "script_args", "raw_args", "output", "track_navigation"],
"adapter" => &["file_path", "function_name", "script_args", "raw_args", "output", "track_navigation"],
"kill-daemon" => &[],
"run-script" => &[
"file_path",
"script_args",
"raw_args",
"output",
"track_navigation",
],
"adapter" => &[
"file_path",
"function_name",
"script_args",
"raw_args",
"output",
"track_navigation",
],
"kill-daemon" => &["force"],
_ => &[],
}
}
Expand Down Expand Up @@ -325,7 +339,10 @@ fn script_exec_args(args: &serde_json::Value) -> Result<ScriptExecArgs<'_>> {
.get("file_path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("file_path required"))?;
let script_args = args.get("script_args").cloned().unwrap_or_else(|| json!({}));
let script_args = args
.get("script_args")
.cloned()
.unwrap_or_else(|| json!({}));
let output = args.get("output").and_then(|v| v.as_str());
let track_navigation = args
.get("track_navigation")
Expand Down Expand Up @@ -412,7 +429,10 @@ async fn inner_execute(
client,
session_id,
commands::screenshot::ScreenshotOptions {
output: args.get("output").and_then(|v| v.as_str()).map(String::from),
output: args
.get("output")
.and_then(|v| v.as_str())
.map(String::from),
format: args
.get("format")
.and_then(|v| v.as_str())
Expand Down
Loading