Compare commits

...
6 Commits
Author SHA1 Message Date
leeguooooo 997373fd57 chore(release): 1.5.15 — trusted activation for in-iframe buttons (#39)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-17 10:16:17 +09:00
leeguooooo 5a858af93f fix(iframe): trusted activation for in-iframe buttons — keyboard, not synthetic click (#39)
A DOM `.click()` is isTrusted:false, which security-sensitive embedded forms
reject — Google Payments' enabled `保存` button silently no-op'd, so a
cross-origin payment/checkout/KYC form could be read, scrolled, and typed into
but never submitted. A coordinate click can't help either: getBoxModel for a
sub-frame node returns frame-local coords that don't compose the iframe offset,
so it lands wrong (verified — the same-origin probe came back isTrusted:false
via the coordinate fallback).

Fix: click on an in-iframe ref now focuses the element in its own frame session
and dispatches a real Enter (Space for checkbox-like roles) on the page session.
Chrome routes the key to the focused element across frames (same mechanism as
`type --focused`), and Enter/Space on a focused button/link/checkbox fires a
trusted click. Non-activatable roles fall back to DOM .click().

Adds e2e_iframe_button_click_is_trusted (+ fixture): an in-iframe button records
event.isTrusted into its own text; the test asserts the ref-click delivers
isTrusted:true.
2026-06-17 10:16:15 +09:00
leeguooooo 58dc02bfdc chore(release): 1.5.14 — fix eval await regression (replMode) + default scroll; green CI (#36, #38)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-17 02:34:55 +09:00
leeguooooo c47601bd7b fix(eval): replMode only for sync let/const decls, keep awaitPromise for async (#38)
replMode and awaitPromise are mutually exclusive in Chrome — under replMode a
returned promise serialises to {} instead of being awaited, which broke every
fetch/async eval (e2e_domain_filter, e2e_headers, e2e_react_tree all regressed).
Enable replMode only for synchronous scripts that declare a top-level let/const
(the #38 case); promise-returning scripts keep awaitPromise — restoring the
pre-#38 await behaviour while still fixing the let-redeclaration collision.
2026-06-17 02:08:11 +09:00
leeguooooo 0296bc7a88 fix(scroll): keep default scroll on window.scrollBy; wheel only for --at/--frame (#36)
The centered-wheel default no-op'd on some pages (headless e2e_hover_scroll_press
regressed). Restore window.scrollBy for plain page scroll; the coordinate wheel
stays opt-in via --at/--frame for cross-origin iframe content.
2026-06-17 02:01:23 +09:00
leeguooooo 32e203b908 style: cargo fmt (fixes the CI format-check failure) 2026-06-17 01:33:22 +09:00
12 changed files with 428 additions and 87 deletions
+1 -1
View File
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrome-use" name = "chrome-use"
version = "1.5.13" version = "1.5.15"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "chrome-use" name = "chrome-use"
version = "1.5.13" version = "1.5.15"
edition = "2021" edition = "2021"
description = "Fast browser automation CLI for AI agents" description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0" license = "Apache-2.0"
+72 -16
View File
@@ -34,11 +34,50 @@ pub enum ParseError {
/// suggestions on an unknown command (issue #29). Not exhaustive — just the /// suggestions on an unknown command (issue #29). Not exhaustive — just the
/// common verbs plus a few known wrong-guesses mapped to the real command. /// common verbs plus a few known wrong-guesses mapped to the real command.
const KNOWN_COMMANDS: &[&str] = &[ const KNOWN_COMMANDS: &[&str] = &[
"open", "navigate", "click", "fill", "type", "press", "snapshot", "screenshot", "eval", "get", "open",
"text", "html", "frames", "find", "wait", "scroll", "hover", "select", "check", "uncheck", "navigate",
"tab", "tabs", "close", "back", "forward", "reload", "sessions", "status", "daemon", "doctor", "click",
"upgrade", "connect", "cookies", "mouse", "keyboard", "stream", "frame", "profiles", "title", "fill",
"url", "is", "drag", "dialog", "upload", "type",
"press",
"snapshot",
"screenshot",
"eval",
"get",
"text",
"html",
"frames",
"find",
"wait",
"scroll",
"hover",
"select",
"check",
"uncheck",
"tab",
"tabs",
"close",
"back",
"forward",
"reload",
"sessions",
"status",
"daemon",
"doctor",
"upgrade",
"connect",
"cookies",
"mouse",
"keyboard",
"stream",
"frame",
"profiles",
"title",
"url",
"is",
"drag",
"dialog",
"upload",
]; ];
/// Levenshtein distance, capped — small inputs only (command names). /// Levenshtein distance, capped — small inputs only (command names).
@@ -547,7 +586,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
context: "type".to_string(), context: "type".to_string(),
usage: "type <selector> <text> (or: type --focused <text>) [--key-events]", usage: "type <selector> <text> (or: type --focused <text>) [--key-events]",
})?; })?;
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" "), "keyEvents": key_events })) Ok(
json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" "), "keyEvents": key_events }),
)
} }
"pick" => { "pick" => {
// pick <selector|@ref> --option "<text>" — atomic combobox select: // pick <selector|@ref> --option "<text>" — atomic combobox select:
@@ -761,7 +802,8 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
_ => { _ => {
return Err(ParseError::InvalidValue { return Err(ParseError::InvalidValue {
message: format!("scroll --at: invalid coordinate `{}`", val), message: format!("scroll --at: invalid coordinate `{}`", val),
usage: "scroll [direction] [amount] --at <x,y> (e.g. --at 640,400)", usage:
"scroll [direction] [amount] --at <x,y> (e.g. --at 640,400)",
}) })
} }
} }
@@ -970,17 +1012,21 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
"--full" | "-f" => full_page = true, "--full" | "-f" => full_page = true,
// `--clip x,y,w,h` captures a pixel region (issue #34). // `--clip x,y,w,h` captures a pixel region (issue #34).
"--clip" => { "--clip" => {
let raw = rest.get(i + 1).ok_or_else(|| ParseError::MissingArguments { let raw = rest
context: "screenshot --clip".to_string(), .get(i + 1)
usage: "screenshot --clip <x,y,w,h> [path]", .ok_or_else(|| ParseError::MissingArguments {
})?; context: "screenshot --clip".to_string(),
usage: "screenshot --clip <x,y,w,h> [path]",
})?;
let nums: Vec<f64> = raw let nums: Vec<f64> = raw
.split(',') .split(',')
.filter_map(|n| n.trim().parse::<f64>().ok()) .filter_map(|n| n.trim().parse::<f64>().ok())
.collect(); .collect();
if nums.len() != 4 { if nums.len() != 4 {
return Err(ParseError::InvalidValue { return Err(ParseError::InvalidValue {
message: format!("--clip expects 'x,y,w,h' (4 numbers), got '{raw}'"), message: format!(
"--clip expects 'x,y,w,h' (4 numbers), got '{raw}'"
),
usage: "screenshot --clip <x,y,w,h> [path]", usage: "screenshot --clip <x,y,w,h> [path]",
}); });
} }
@@ -4128,7 +4174,11 @@ mod tests {
fn test_type_key_events() { fn test_type_key_events() {
// --key-events sends real keystrokes (for autocomplete/combobox) and must // --key-events sends real keystrokes (for autocomplete/combobox) and must
// not be swallowed into the typed text. // not be swallowed into the typed text.
let cmd = parse_command(&args("type #postal 201-0001 --key-events"), &default_flags()).unwrap(); let cmd = parse_command(
&args("type #postal 201-0001 --key-events"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "type"); assert_eq!(cmd["action"], "type");
assert_eq!(cmd["selector"], "#postal"); assert_eq!(cmd["selector"], "#postal");
assert_eq!(cmd["text"], "201-0001"); assert_eq!(cmd["text"], "201-0001");
@@ -4426,8 +4476,11 @@ mod tests {
#[test] #[test]
fn test_screenshot_clip() { fn test_screenshot_clip() {
// `--clip x,y,w,h` captures a pixel region (issue #34); the path still parses. // `--clip x,y,w,h` captures a pixel region (issue #34); the path still parses.
let cmd = parse_command(&args("screenshot --clip 10,20,200,40 out.png"), &default_flags()) let cmd = parse_command(
.unwrap(); &args("screenshot --clip 10,20,200,40 out.png"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "screenshot"); assert_eq!(cmd["action"], "screenshot");
assert_eq!(cmd["clip"]["x"], 10.0); assert_eq!(cmd["clip"]["x"], 10.0);
assert_eq!(cmd["clip"]["y"], 20.0); assert_eq!(cmd["clip"]["y"], 20.0);
@@ -5005,7 +5058,10 @@ mod tests {
assert_eq!(nearest_command("sesions").as_deref(), Some("sessions")); assert_eq!(nearest_command("sesions").as_deref(), Some("sessions"));
assert_eq!(nearest_command("session").as_deref(), Some("sessions")); assert_eq!(nearest_command("session").as_deref(), Some("sessions"));
assert_eq!(nearest_command("clik").as_deref(), Some("click")); assert_eq!(nearest_command("clik").as_deref(), Some("click"));
assert_eq!(nearest_command("screenshits").as_deref(), Some("screenshot")); assert_eq!(
nearest_command("screenshits").as_deref(),
Some("screenshot")
);
// Nonsense with no close match stays silent. // Nonsense with no close match stays silent.
assert_eq!(nearest_command("xyzzy"), None); assert_eq!(nearest_command("xyzzy"), None);
// The unknown-command error embeds the suggestion. // The unknown-command error embeds the suggestion.
+48 -29
View File
@@ -3244,8 +3244,14 @@ async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
.get("text") .get("text")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or("Missing 'text' parameter")?; .ok_or("Missing 'text' parameter")?;
interaction::type_text_into_active_context(&mgr.client, &session_id, text, None, key_events) interaction::type_text_into_active_context(
.await?; &mgr.client,
&session_id,
text,
None,
key_events,
)
.await?;
return Ok(json!({ "typed": text, "focused": true })); return Ok(json!({ "typed": text, "focused": true }));
} }
@@ -3493,27 +3499,40 @@ async fn handle_scroll(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
return Ok(json!({ "scrolled": true, "via": "selector" })); return Ok(json!({ "scrolled": true, "via": "selector" }));
} }
// No selector: dispatch a real (isTrusted) wheel at a viewport coordinate. // `--at x,y` / `--frame n`: dispatch a real (isTrusted) wheel at a viewport
// This hits the compositor and scrolls whatever scroll container is under the // coordinate. This hits the compositor and scrolls whatever scroll container
// pointer — including cross-origin iframes that `window.scrollBy` on the top // is under the pointer — including cross-origin iframes that `window.scrollBy`
// document silently no-ops on (issue #36). The coordinate is, in priority: // on the top document silently no-ops on (issue #36).
// --at x,y → that exact pixel if cmd.get("at").is_some() || cmd.get("frame").is_some() {
// --frame n → the center of frame n from `chrome-use frames` let (x, y, via) = if let Some(at) = cmd.get("at").and_then(|v| v.as_array()) {
// default → the viewport center let x = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
let (x, y, via) = if let Some(at) = cmd.get("at").and_then(|v| v.as_array()) { let y = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
let x = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0); (x, y, "at")
let y = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0); } else {
(x, y, "at") let n = cmd.get("frame").and_then(|v| v.as_u64()).unwrap_or(0);
} else if let Some(n) = cmd.get("frame").and_then(|v| v.as_u64()) { let (x, y) = frame_center(mgr, &session_id, &state.iframe_sessions, n as usize).await?;
let (x, y) = frame_center(mgr, &session_id, &state.iframe_sessions, n as usize).await?; (x, y, "frame")
(x, y, "frame") };
} else { dispatch_wheel(&mgr.client, &session_id, x, y, dx, dy).await?;
let (x, y) = viewport_center(mgr, &session_id).await?; return Ok(json!({ "scrolled": true, "via": via, "at": [x, y] }));
(x, y, "center") }
};
dispatch_wheel(&mgr.client, &session_id, x, y, dx, dy).await?; // Default (no selector/at/frame): scroll the page with `window.scrollBy`. This
Ok(json!({ "scrolled": true, "via": via, "at": [x, y] })) // is the reliable path for ordinary page scrolling; a coordinate wheel at the
// viewport centre is NOT a dependable substitute (it no-ops on some pages,
// e.g. headless), so the wheel stays opt-in via `--at`/`--frame` for the
// cross-origin-iframe case (issue #36).
interaction::scroll(
&mgr.client,
&session_id,
&state.ref_map,
None,
dx,
dy,
&state.iframe_sessions,
)
.await?;
Ok(json!({ "scrolled": true, "via": "page" }))
} }
/// Viewport center in CSS pixels, used as the default wheel landing point for /// Viewport center in CSS pixels, used as the default wheel landing point for
@@ -3836,12 +3855,9 @@ async fn handle_gettext(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
async fn handle_frames(_cmd: &Value, state: &mut DaemonState) -> Result<Value, String> { async fn handle_frames(_cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string(); let session_id = mgr.active_session_id()?.to_string();
let frames = super::element::collect_all_frames_text( let frames =
&mgr.client, super::element::collect_all_frames_text(&mgr.client, &session_id, &state.iframe_sessions)
&session_id, .await?;
&state.iframe_sessions,
)
.await?;
let list: Vec<Value> = frames let list: Vec<Value> = frames
.iter() .iter()
.enumerate() .enumerate()
@@ -4337,7 +4353,10 @@ async fn handle_cf_status(_cmd: &Value, state: &mut DaemonState) -> Result<Value
let url = mgr.get_url().await.unwrap_or_default(); let url = mgr.get_url().await.unwrap_or_default();
// 1. Is the page a Cloudflare challenge right now? // 1. Is the page a Cloudflare challenge right now?
let probe_raw = mgr.evaluate(CF_CHALLENGE_JS, None).await.unwrap_or(Value::Null); let probe_raw = mgr
.evaluate(CF_CHALLENGE_JS, None)
.await
.unwrap_or(Value::Null);
let probe = parse_json_string(probe_raw, "cf challenge probe").unwrap_or(Value::Null); let probe = parse_json_string(probe_raw, "cf challenge probe").unwrap_or(Value::Null);
let challenged = probe let challenged = probe
.get("challenged") .get("challenged")
+42 -16
View File
@@ -579,7 +579,10 @@ impl BrowserManager {
crate::connect::log_connect_mode( crate::connect::log_connect_mode(
&ws_url, &ws_url,
true, true,
DAEMON_SESSION.get().map(String::as_str).unwrap_or("default"), DAEMON_SESSION
.get()
.map(String::as_str)
.unwrap_or("default"),
); );
let manager = if engine == "lightpanda" { let manager = if engine == "lightpanda" {
initialize_lightpanda_manager(ws_url, process).await? initialize_lightpanda_manager(ws_url, process).await?
@@ -681,7 +684,10 @@ impl BrowserManager {
crate::connect::log_connect_mode( crate::connect::log_connect_mode(
&ws_url, &ws_url,
false, false,
DAEMON_SESSION.get().map(String::as_str).unwrap_or("default"), DAEMON_SESSION
.get()
.map(String::as_str)
.unwrap_or("default"),
); );
let client = Arc::new(CdpClient::connect_with_headers(&ws_url, headers).await?); let client = Arc::new(CdpClient::connect_with_headers(&ws_url, headers).await?);
let mut manager = Self { let mut manager = Self {
@@ -1202,13 +1208,21 @@ impl BrowserManager {
pub async fn evaluate(&self, script: &str, _args: Option<Value>) -> Result<Value, String> { pub async fn evaluate(&self, script: &str, _args: Option<Value>) -> Result<Value, String> {
let session_id = self.active_session_id()?.to_string(); let session_id = self.active_session_id()?.to_string();
// `replMode: true` matches the DevTools console: top-level `let`/`const` // `replMode: true` lets successive `eval`s re-declare top-level
// can be re-declared across successive `eval`s instead of throwing // `let`/`const` instead of throwing "Identifier 'x' has already been
// "Identifier 'x' has already been declared" (issue #38 — independent // declared" (issue #38 — independent `eval` steps in a test suite collided
// `eval` steps in a test suite collided in the page's shared lexical // in the page's shared lexical scope). BUT replMode and `awaitPromise` are
// scope), and top-level `await` is allowed. Completion-value and // mutually exclusive in Chrome: under replMode a returned promise is NOT
// main-world semantics are unchanged. Built as raw params so the other // awaited (it serialises to `{}`), which breaks `fetch(...).then(...)` and
// ~28 EvaluateParams literals don't all need a new field. // every other async eval. So enable replMode ONLY for synchronous scripts
// that declare a top-level `let`/`const`; promise-returning scripts keep
// `awaitPromise` (no replMode) — exactly the pre-#38 behaviour.
let mentions_async = script.contains("await")
|| script.contains(".then(")
|| script.contains("fetch(")
|| script.contains("Promise");
let declares = script.contains("let ") || script.contains("const ");
let repl_mode = declares && !mentions_async;
let result: EvaluateResult = self let result: EvaluateResult = self
.client .client
.send_command_typed( .send_command_typed(
@@ -1216,8 +1230,8 @@ impl BrowserManager {
&json!({ &json!({
"expression": script, "expression": script,
"returnByValue": true, "returnByValue": true,
"awaitPromise": true, "awaitPromise": !repl_mode,
"replMode": true, "replMode": repl_mode,
}), }),
Some(&session_id), Some(&session_id),
) )
@@ -2815,7 +2829,9 @@ mod tests {
its tab is gone (closed, navigated across processes, or lost after an extension \ its tab is gone (closed, navigated across processes, or lost after an extension \
restart). Re-attach by re-opening your target URL before retrying." restart). Re-attach by re-opening your target URL before retrying."
)); ));
assert!(is_stale_target_error("unknown sessionId cb-tab-7 for Page.navigate")); assert!(is_stale_target_error(
"unknown sessionId cb-tab-7 for Page.navigate"
));
assert!(is_stale_target_error("no attached tab for Page.navigate")); assert!(is_stale_target_error("no attached tab for Page.navigate"));
} }
@@ -2823,8 +2839,12 @@ mod tests {
fn stale_target_error_ignores_unrelated_failures() { fn stale_target_error_ignores_unrelated_failures() {
// A genuine navigation failure (bad URL, DNS, blocked) must NOT trigger // A genuine navigation failure (bad URL, DNS, blocked) must NOT trigger
// the open-a-fresh-tab recovery — that would mask the real error. // the open-a-fresh-tab recovery — that would mask the real error.
assert!(!is_stale_target_error("Navigation failed: net::ERR_NAME_NOT_RESOLVED")); assert!(!is_stale_target_error(
assert!(!is_stale_target_error("CDP command timed out: Page.navigate")); "Navigation failed: net::ERR_NAME_NOT_RESOLVED"
));
assert!(!is_stale_target_error(
"CDP command timed out: Page.navigate"
));
} }
fn page(target_id: &str) -> PageInfo { fn page(target_id: &str) -> PageInfo {
@@ -2931,7 +2951,10 @@ mod tests {
let dirty = "\u{200d}\u{2061}\u{200d}\u{2063}\u{200b}\u{2062}\u{feff}GitHub"; let dirty = "\u{200d}\u{2061}\u{200d}\u{2063}\u{200b}\u{2062}\u{feff}GitHub";
assert_eq!(sanitize_title(dirty), "GitHub"); assert_eq!(sanitize_title(dirty), "GitHub");
// Clean titles (incl. CJK + normal punctuation) pass through untouched. // Clean titles (incl. CJK + normal punctuation) pass through untouched.
assert_eq!(sanitize_title("購入手続きへ - メルカリ"), "購入手続きへ - メルカリ"); assert_eq!(
sanitize_title("購入手続きへ - メルカリ"),
"購入手続きへ - メルカリ"
);
assert_eq!(sanitize_title(" Hello World "), "Hello World"); assert_eq!(sanitize_title(" Hello World "), "Hello World");
// Emoji and real content survive; only the invisibles are dropped. // Emoji and real content survive; only the invisibles are dropped.
assert_eq!(sanitize_title("✓ Done\u{200b}"), "✓ Done"); assert_eq!(sanitize_title("✓ Done\u{200b}"), "✓ Done");
@@ -2963,7 +2986,10 @@ mod tests {
// A pinned target that IS in the live set is simply not prunable anyway. // A pinned target that IS in the live set is simply not prunable anyway.
let mut live2 = HashSet::new(); let mut live2 = HashSet::new();
live2.insert("A".to_string()); live2.insert("A".to_string());
assert_eq!(prunable_target_ids(&pages, &live2, Some("A")), vec!["B".to_string()]); assert_eq!(
prunable_target_ids(&pages, &live2, Some("A")),
vec!["B".to_string()]
);
} }
#[test] #[test]
+71
View File
@@ -35,6 +35,7 @@ fn native_test_fixture_html(name: &str) -> &'static str {
"html5_drag_probe" => include_str!("test_fixtures/html5_drag_probe.html"), "html5_drag_probe" => include_str!("test_fixtures/html5_drag_probe.html"),
"pointer_capture_probe" => include_str!("test_fixtures/pointer_capture_probe.html"), "pointer_capture_probe" => include_str!("test_fixtures/pointer_capture_probe.html"),
"upload_probe" => include_str!("test_fixtures/upload_probe.html"), "upload_probe" => include_str!("test_fixtures/upload_probe.html"),
"iframe_button_probe" => include_str!("test_fixtures/iframe_button_probe.html"),
_ => panic!("Unknown native test fixture: {}", name), _ => panic!("Unknown native test fixture: {}", name),
} }
} }
@@ -573,6 +574,76 @@ async fn e2e_snapshot_and_click_ref() {
assert_success(&resp); assert_success(&resp);
} }
/// Clicking a button INSIDE an iframe by `@ref` must deliver a TRUSTED activation
/// (`event.isTrusted === true`), not a synthetic DOM `.click()`. Security-sensitive
/// embedded forms (Google Payments' `保存`) reject `isTrusted:false` clicks, so an
/// enabled submit button silently no-op'd (issue #39). The fix routes iframe-ref
/// clicks to a real `Input.dispatchMouseEvent` on the element's own frame session.
/// The fixture's iframe button writes `clicked:<isTrusted>` into its own text on
/// click, which the cross-frame snapshot reads back.
#[tokio::test]
#[ignore]
async fn e2e_iframe_button_click_is_trusted() {
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "2", "action": "navigate", "url": native_test_fixture_url("iframe_button_probe") }),
&mut state,
)
.await;
assert_success(&resp);
// Snapshot (interactive) — the button lives in the iframe and must appear with
// a ref; that ref carries the frame_id so the click resolves into the frame.
let resp = execute_command(
&json!({ "id": "3", "action": "snapshot", "interactive": true }),
&mut state,
)
.await;
assert_success(&resp);
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap_or("");
let ref_id = snapshot
.lines()
.find(|l| l.contains("button \"save\""))
.and_then(|l| l.split("ref=").nth(1))
.map(|r| r.trim_end_matches(']').trim())
.unwrap_or_else(|| panic!("iframe button not found in snapshot:\n{snapshot}"));
// Click it by ref.
let resp = execute_command(
&json!({ "id": "4", "action": "click", "selector": ref_id }),
&mut state,
)
.await;
assert_success(&resp);
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
// The button rewrote its own text with the click's isTrusted flag; read it
// back across frames.
let resp = execute_command(
&json!({ "id": "5", "action": "snapshot", "interactive": true }),
&mut state,
)
.await;
assert_success(&resp);
let after = get_data(&resp)["snapshot"].as_str().unwrap_or("");
assert!(
after.contains("clicked:true"),
"iframe button click must be trusted (isTrusted:true); snapshot:\n{after}"
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Screenshot // Screenshot
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+7 -2
View File
@@ -1028,7 +1028,9 @@ async fn eval_text_in_frame(client: &CdpClient, session_id: &str, frame_id: &str
.await .await
.ok() .ok()
.and_then(|v| v.get("executionContextId").and_then(|c| c.as_i64())); .and_then(|v| v.get("executionContextId").and_then(|c| c.as_i64()));
let Some(ctx_id) = ctx else { return String::new() }; let Some(ctx_id) = ctx else {
return String::new();
};
let res = client let res = client
.send_command( .send_command(
"Runtime.evaluate", "Runtime.evaluate",
@@ -1099,7 +1101,10 @@ pub async fn collect_all_frames_text(
let (kind, text) = if is_top { let (kind, text) = if is_top {
("top", eval_text_default(client, top_session).await) ("top", eval_text_default(client, top_session).await)
} else { } else {
("inline", eval_text_in_frame(client, top_session, &fid).await) (
"inline",
eval_text_in_frame(client, top_session, &fid).await,
)
}; };
out.push(FrameText { out.push(FrameText {
frame_id: fid, frame_id: fid,
+110 -12
View File
@@ -56,16 +56,35 @@ pub async fn click(
.await; .await;
} }
// Over the extension relay we drive the user's real, in-use Chrome, where a // An element INSIDE an iframe needs a TRUSTED activation: a DOM `.click()` is
// coordinate `Input.dispatchMouseEvent` is NOT reliably confined to our target // `isTrusted:false`, which security-sensitive embedded forms reject — Google
// tab — it can be delivered to whatever tab is in the foreground, and an OOPIF // Payments' enabled `保存` button silently no-ops on a synthetic click (issue
// element's box can't be mapped to a top-viewport point at all. This twice // #39). A coordinate `Input.dispatchMouseEvent` can't help either: `getBoxModel`
// opened an unrelated tab on the user's busy Chrome (issues #31/#36). So on the // for a sub-frame node returns frame-local coordinates that don't compose the
// relay, never use coordinates for a normal left click: DOM-dispatch invokes // iframe's offset, so the click lands in the wrong place. The frame-agnostic
// the element's click in its own (frame) session, always hitting the right // trusted path is keyboard activation — focus the element in its own frame, then
// element in the right tab. Double/right clicks still need true pointer // dispatch a real Enter on the page session; Chrome routes the key to the
// semantics, and `coord` mode is an explicit opt-out. // focused element regardless of frame (same as `type --focused`), and Enter on a
if mode != "coord" && button == "left" && click_count == 1 && prefer_dom_dispatch(ref_map, selector_or_ref) { // focused button/link fires a trusted `click`. `coord` mode opts out.
let in_iframe = ref_map.ref_is_in_iframe(selector_or_ref);
if mode != "coord" && button == "left" && click_count == 1 && in_iframe {
return dom_activate(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
}
// On the relay (the user's real Chrome) a TOP-document coordinate click used to
// drift onto the foreground tab; that root cause is fixed (#5: the agent drives
// its own pinned tab), but DOM-dispatch stays the conservative default here.
if mode != "coord"
&& button == "left"
&& click_count == 1
&& crate::connect::relay_url().is_some()
{
return dom_click( return dom_click(
client, client,
session_id, session_id,
@@ -266,6 +285,71 @@ async fn dom_click(
Ok(()) Ok(())
} }
/// Trusted activation of an element inside an iframe (issue #39). Focuses the
/// element in its own frame session, then dispatches a real Enter/Space on the
/// page session — Chrome routes the key to the focused element across frames, and
/// Enter/Space on a focused button/link/checkbox fires a `click` with
/// `isTrusted: true`, which security-sensitive embedded forms (Google Payments
/// `保存`) require. Non-activatable roles (a `div[onclick]`) can't be keyboard-
/// activated, so they fall back to a DOM `.click()`.
async fn dom_activate(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let role = parse_ref(selector_or_ref)
.and_then(|r| ref_map.get(&r).map(|e| e.role.clone()))
.unwrap_or_default();
// Space toggles checkbox-like controls; Enter activates buttons/links/menus.
let key = match role.as_str() {
"checkbox" | "radio" | "switch" | "option" | "menuitemcheckbox" | "menuitemradio" => {
Some("space")
}
"button" | "link" | "menuitem" | "tab" | "treeitem" => Some("enter"),
_ => None,
};
let Some(key) = key else {
// Not keyboard-activatable — best effort via DOM .click() (untrusted).
return dom_click(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
};
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
// Focus the element in its OWN frame session so the keystroke lands on it.
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(&effective_session_id),
)
.await?;
// Trusted key on the page session — routed to the focused (in-frame) element.
press_key(client, session_id, key).await?;
wait_for_paint_settled(client, &effective_session_id).await;
Ok(())
}
/// DOM-dispatch a double-click on the element in its own session (no coordinates) /// DOM-dispatch a double-click on the element in its own session (no coordinates)
/// — the relay/iframe-safe counterpart to a coordinate dblclick. Fires the full /// — the relay/iframe-safe counterpart to a coordinate dblclick. Fires the full
/// click,click,dblclick sequence so handlers bound to any of them respond. /// click,click,dblclick sequence so handlers bound to any of them respond.
@@ -319,7 +403,14 @@ pub async fn dblclick(
if std::env::var("AGENT_BROWSER_CLICK_MODE").as_deref() != Ok("coord") if std::env::var("AGENT_BROWSER_CLICK_MODE").as_deref() != Ok("coord")
&& prefer_dom_dispatch(ref_map, selector_or_ref) && prefer_dom_dispatch(ref_map, selector_or_ref)
{ {
return dom_dblclick(client, session_id, ref_map, selector_or_ref, iframe_sessions).await; return dom_dblclick(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
} }
click( click(
client, client,
@@ -387,7 +478,14 @@ pub async fn hover(
// Coordinate `mouseMoved` drifts to the foreground tab over the relay and // Coordinate `mouseMoved` drifts to the foreground tab over the relay and
// can't reach an OOPIF — DOM-dispatch the hover there (issues #31/#36). // can't reach an OOPIF — DOM-dispatch the hover there (issues #31/#36).
if prefer_dom_dispatch(ref_map, selector_or_ref) { if prefer_dom_dispatch(ref_map, selector_or_ref) {
return dom_hover(client, session_id, ref_map, selector_or_ref, iframe_sessions).await; return dom_hover(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
} }
let (x, y, _w, _h, effective_session_id) = resolve_element_center( let (x, y, _w, _h, effective_session_id) = resolve_element_center(
client, client,
+10 -1
View File
@@ -345,7 +345,16 @@ pub async fn take_snapshot(
frame_id: Option<&str>, frame_id: Option<&str>,
iframe_sessions: &HashMap<String, String>, iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> { ) -> Result<String, String> {
take_snapshot_at_depth(client, session_id, options, ref_map, frame_id, iframe_sessions, 0).await take_snapshot_at_depth(
client,
session_id,
options,
ref_map,
frame_id,
iframe_sessions,
0,
)
.await
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe button probe</title>
</head>
<body>
<h1>iframe button probe</h1>
<iframe
id="frame"
width="320"
height="140"
srcdoc="
<!doctype html>
<html>
<body style='margin:24px'>
<button id='b' style='padding:24px;font-size:22px'>save</button>
<script>
document.getElementById('b').addEventListener('click', function (e) {
this.textContent = 'clicked:' + e.isTrusted;
});
</script>
</body>
</html>
"
></iframe>
</body>
</html>
+37 -8
View File
@@ -228,13 +228,28 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// because its response carries `url`/`title`, which later generic // because its response carries `url`/`title`, which later generic
// renderers would otherwise swallow. // renderers would otherwise swallow.
if action == Some("cf_status") { if action == Some("cf_status") {
let challenged = data.get("challenged").and_then(|v| v.as_bool()).unwrap_or(false); let challenged = data
let rec = data.get("recommendation").and_then(|v| v.as_str()).unwrap_or("?"); .get("challenged")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let rec = data
.get("recommendation")
.and_then(|v| v.as_str())
.unwrap_or("?");
let cl = data.get("clearance"); let cl = data.get("clearance");
let present = cl.and_then(|c| c.get("present")).and_then(|v| v.as_bool()).unwrap_or(false); let present = cl
let expired = cl.and_then(|c| c.get("expired")).and_then(|v| v.as_bool()).unwrap_or(false); .and_then(|c| c.get("present"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let expired = cl
.and_then(|c| c.get("expired"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let expires_in = cl.and_then(|c| c.get("expiresIn")).and_then(|v| v.as_i64()); let expires_in = cl.and_then(|c| c.get("expiresIn")).and_then(|v| v.as_i64());
let device = data.get("deviceVerified").and_then(|v| v.as_bool()).unwrap_or(false); let device = data
.get("deviceVerified")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let (icon, headline) = match rec { let (icon, headline) = match rec {
"proceed" => (color::success_indicator().to_string(), "cleared — no challenge, proceed"), "proceed" => (color::success_indicator().to_string(), "cleared — no challenge, proceed"),
@@ -243,7 +258,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
_ => (color::cyan("").to_string(), "unknown"), _ => (color::cyan("").to_string(), "unknown"),
}; };
println!("{} {}", icon, headline); println!("{} {}", icon, headline);
println!(" challenged: {}", if challenged { "yes" } else { "no" }); println!(
" challenged: {}",
if challenged { "yes" } else { "no" }
);
let cl_desc = if !present { let cl_desc = if !present {
"absent".to_string() "absent".to_string()
} else if expired { } else if expired {
@@ -254,7 +272,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
"present (session)".to_string() "present (session)".to_string()
}; };
println!(" cf_clearance: {}", cl_desc); println!(" cf_clearance: {}", cl_desc);
println!(" device trusted: {}", if device { "yes (CF_VERIFIED_DEVICE)" } else { "no" }); println!(
" device trusted: {}",
if device {
"yes (CF_VERIFIED_DEVICE)"
} else {
"no"
}
);
return; return;
} }
@@ -382,7 +407,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
let count = list.len(); let count = list.len();
println!( println!(
"{}", "{}",
color::bold(&format!("{} frame{}", count, if count == 1 { "" } else { "s" })) color::bold(&format!(
"{} frame{}",
count,
if count == 1 { "" } else { "s" }
))
); );
for f in list { for f in list {
let idx = f.get("index").and_then(|v| v.as_i64()).unwrap_or(0); let idx = f.get("index").and_then(|v| v.as_i64()).unwrap_or(0);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "chrome-use", "name": "chrome-use",
"version": "1.5.13", "version": "1.5.15",
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default", "description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
"type": "module", "type": "module",
"packageManager": "pnpm@11.1.3", "packageManager": "pnpm@11.1.3",