Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be01e292d | ||
|
|
a83d1b1df9 |
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.5.20"
|
||||
version = "1.5.22"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.5.20"
|
||||
version = "1.5.22"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+84
-2
@@ -79,6 +79,7 @@ const KNOWN_COMMANDS: &[&str] = &[
|
||||
"dialog",
|
||||
"upload",
|
||||
"site",
|
||||
"box",
|
||||
];
|
||||
|
||||
/// Levenshtein distance, capped — small inputs only (command names).
|
||||
@@ -560,9 +561,37 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
"fill" => {
|
||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "fill".to_string(),
|
||||
usage: "fill <selector> <text>",
|
||||
usage: "fill <selector> <text> | fill <selector> --file <path> | fill <selector> --stdin",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
|
||||
// Large/multiline content without shell-escaping hell (issue #41):
|
||||
// `fill <sel> --file <path>` reads the value from a UTF-8 file, and
|
||||
// `fill <sel> --stdin` reads it from stdin — sent verbatim, so backticks,
|
||||
// quotes, newlines and non-ASCII pass through untouched.
|
||||
let value = match rest.get(1).copied() {
|
||||
Some("--file") => {
|
||||
let path = rest.get(2).ok_or(ParseError::InvalidValue {
|
||||
message: "fill --file requires a path".to_string(),
|
||||
usage: "fill <selector> --file <path>",
|
||||
})?;
|
||||
std::fs::read_to_string(path).map_err(|e| ParseError::InvalidValue {
|
||||
message: format!("fill --file: cannot read {path}: {e}"),
|
||||
usage: "fill <selector> --file <path>",
|
||||
})?
|
||||
}
|
||||
Some("--stdin") => {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.map_err(|e| ParseError::InvalidValue {
|
||||
message: format!("fill --stdin: {e}"),
|
||||
usage: "fill <selector> --stdin",
|
||||
})?;
|
||||
buf
|
||||
}
|
||||
_ => rest[1..].join(" "),
|
||||
};
|
||||
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": value }))
|
||||
}
|
||||
"type" => {
|
||||
// `--key-events` (alias `--keys`): send real per-character keystrokes
|
||||
@@ -1006,11 +1035,55 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
// path: file path (contains / or . or ends with known extension)
|
||||
let mut full_page = false;
|
||||
let mut clip: Option<Value> = None;
|
||||
let mut max_width: Option<u32> = None;
|
||||
let mut max_height: Option<u32> = None;
|
||||
let mut scale: Option<f64> = None;
|
||||
let mut positional: Vec<&str> = Vec::new();
|
||||
let mut i = 0;
|
||||
// Parse a numeric value for a downscale flag (issue #42).
|
||||
let parse_num = |i: &mut usize, flag: &str| -> Result<String, ParseError> {
|
||||
let v = rest
|
||||
.get(*i + 1)
|
||||
.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: format!("screenshot {flag}"),
|
||||
usage: "screenshot [--max-width <px>] [--max-height <px>] [--scale <0..1>]",
|
||||
})?;
|
||||
*i += 1;
|
||||
Ok(v.to_string())
|
||||
};
|
||||
while i < rest.len() {
|
||||
match rest[i] {
|
||||
"--full" | "-f" => full_page = true,
|
||||
// Downscale the saved image so retina/full-page shots fit an
|
||||
// agent's image reader and screenshot px line up with click px (#42).
|
||||
"--max-width" => {
|
||||
let v = parse_num(&mut i, "--max-width")?;
|
||||
max_width = Some(v.parse().map_err(|_| ParseError::InvalidValue {
|
||||
message: format!("--max-width expects a number, got '{v}'"),
|
||||
usage: "screenshot --max-width <px>",
|
||||
})?);
|
||||
}
|
||||
"--max-height" => {
|
||||
let v = parse_num(&mut i, "--max-height")?;
|
||||
max_height = Some(v.parse().map_err(|_| ParseError::InvalidValue {
|
||||
message: format!("--max-height expects a number, got '{v}'"),
|
||||
usage: "screenshot --max-height <px>",
|
||||
})?);
|
||||
}
|
||||
"--scale" => {
|
||||
let v = parse_num(&mut i, "--scale")?;
|
||||
let s: f64 = v.parse().map_err(|_| ParseError::InvalidValue {
|
||||
message: format!("--scale expects a number like 0.5, got '{v}'"),
|
||||
usage: "screenshot --scale <0..1>",
|
||||
})?;
|
||||
if s <= 0.0 || s > 1.0 {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("--scale must be in (0, 1], got '{v}'"),
|
||||
usage: "screenshot --scale <0..1>",
|
||||
});
|
||||
}
|
||||
scale = Some(s);
|
||||
}
|
||||
// `--clip x,y,w,h` captures a pixel region (issue #34).
|
||||
"--clip" => {
|
||||
let raw = rest
|
||||
@@ -1073,6 +1146,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
if let Some(c) = clip {
|
||||
cmd["clip"] = c;
|
||||
}
|
||||
if let Some(w) = max_width {
|
||||
cmd["maxWidth"] = json!(w);
|
||||
}
|
||||
if let Some(h) = max_height {
|
||||
cmd["maxHeight"] = json!(h);
|
||||
}
|
||||
if let Some(s) = scale {
|
||||
cmd["scale"] = json!(s);
|
||||
}
|
||||
if let Some(ref fmt) = flags.screenshot_format {
|
||||
cmd["format"] = json!(fmt);
|
||||
}
|
||||
|
||||
@@ -3114,7 +3114,40 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Downscale the saved image so retina/full-page shots fit an agent's image
|
||||
// reader and screenshot pixels line up with `click x y` CSS px (issue #42).
|
||||
// Explicit --scale / --max-width / --max-height win; otherwise a default cap
|
||||
// (2000px longest edge, AGENT_BROWSER_SCREENSHOT_MAX_EDGE overrides, 0 = off)
|
||||
// applies. Annotated shots are left untouched so ref overlays stay aligned.
|
||||
let mut resized: Option<(u32, u32)> = None;
|
||||
if !annotate {
|
||||
let scale = cmd.get("scale").and_then(|v| v.as_f64());
|
||||
let max_w = cmd
|
||||
.get("maxWidth")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let max_h = cmd
|
||||
.get("maxHeight")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let default_edge = if scale.is_none() && max_w.is_none() && max_h.is_none() {
|
||||
std::env::var("AGENT_BROWSER_SCREENSHOT_MAX_EDGE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.or(Some(2000))
|
||||
.filter(|&e| e > 0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
resized = downscale_screenshot(&result.path, scale, max_w, max_h, default_edge);
|
||||
}
|
||||
|
||||
let mut response = json!({ "path": absolutize_saved_path(&result.path) });
|
||||
if let Some((w, h)) = resized {
|
||||
response["width"] = json!(w);
|
||||
response["height"] = json!(h);
|
||||
response["resized"] = json!(true);
|
||||
}
|
||||
if !result.annotations.is_empty() {
|
||||
response["annotations"] = serde_json::to_value(&result.annotations)
|
||||
.map_err(|e| format!("Failed to serialize annotations: {}", e))?;
|
||||
@@ -3130,6 +3163,56 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Downscale a saved screenshot in place (issue #42). Resolves the target longest
|
||||
/// edge from `scale` (fraction of current), explicit `max_w`/`max_h` caps, or a
|
||||
/// `default_edge` cap — whichever yields the smaller image. Only ever shrinks;
|
||||
/// no-op (returns None) if the image is already within bounds or can't be read.
|
||||
/// Returns the new (width, height) when it actually resized.
|
||||
fn downscale_screenshot(
|
||||
path: &str,
|
||||
scale: Option<f64>,
|
||||
max_w: Option<u32>,
|
||||
max_h: Option<u32>,
|
||||
default_edge: Option<u32>,
|
||||
) -> Option<(u32, u32)> {
|
||||
let img = image::open(path).ok()?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Collect candidate scale factors (≤ 1.0); the smallest wins.
|
||||
let mut factor = 1.0f64;
|
||||
if let Some(s) = scale {
|
||||
factor = factor.min(s);
|
||||
}
|
||||
if let Some(mw) = max_w {
|
||||
if w > mw {
|
||||
factor = factor.min(mw as f64 / w as f64);
|
||||
}
|
||||
}
|
||||
if let Some(mh) = max_h {
|
||||
if h > mh {
|
||||
factor = factor.min(mh as f64 / h as f64);
|
||||
}
|
||||
}
|
||||
if let Some(edge) = default_edge {
|
||||
let longest = w.max(h);
|
||||
if longest > edge {
|
||||
factor = factor.min(edge as f64 / longest as f64);
|
||||
}
|
||||
}
|
||||
|
||||
if factor >= 1.0 {
|
||||
return None; // already within bounds — never upscale
|
||||
}
|
||||
let nw = ((w as f64 * factor).round() as u32).max(1);
|
||||
let nh = ((h as f64 * factor).round() as u32).max(1);
|
||||
let resized = img.resize(nw, nh, image::imageops::FilterType::Lanczos3);
|
||||
resized.save(path).ok()?;
|
||||
Some((resized.width(), resized.height()))
|
||||
}
|
||||
|
||||
async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
// First-class coordinate click (issue #8.4): click a raw viewport point with
|
||||
// no element resolution. Parsed from `click <x> <y>` / `click --coords x,y`.
|
||||
@@ -3288,7 +3371,7 @@ async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let session_id = mgr.active_session_id()?.to_string();
|
||||
|
||||
interaction::fill(
|
||||
let engine = interaction::fill(
|
||||
&mgr.client,
|
||||
&session_id,
|
||||
&state.ref_map,
|
||||
@@ -3297,7 +3380,9 @@ async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
&state.iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({ "filled": selector }))
|
||||
// Echo the input path used (input/contenteditable/codemirror5/monaco/select)
|
||||
// so the agent can confirm a rich editor was handled, not silently no-op'd (#41).
|
||||
Ok(json!({ "filled": selector, "engine": engine }))
|
||||
}
|
||||
|
||||
async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
|
||||
+55
-17
@@ -509,6 +509,13 @@ pub struct BrowserManager {
|
||||
/// for `RELAY_PRUNE_MISSES` consecutive snapshots; any snapshot that includes
|
||||
/// it resets the counter. Keyed by stable target_id.
|
||||
relay_target_misses: HashMap<String, u32>,
|
||||
/// Whether the relay accepted this session's group announcement and is
|
||||
/// therefore scoping `Target.getTargets` to our own tab group (issue #40).
|
||||
/// When true the daemon can safely adopt new targets again (follow-popup,
|
||||
/// cross-session adopt) — the relay has already filtered out foreign tabs.
|
||||
/// When false (launch-on-real-CDP, or an older relay that didn't answer the
|
||||
/// announce) the daemon keeps strict daemon-side isolation.
|
||||
relay_scoped: bool,
|
||||
next_tab_id: u32,
|
||||
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
|
||||
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
|
||||
@@ -646,6 +653,7 @@ impl BrowserManager {
|
||||
created_targets: HashSet::new(),
|
||||
active_target_id: None,
|
||||
relay_target_misses: HashMap::new(),
|
||||
relay_scoped: false,
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
@@ -749,6 +757,7 @@ impl BrowserManager {
|
||||
created_targets: HashSet::new(),
|
||||
active_target_id: None,
|
||||
relay_target_misses: HashMap::new(),
|
||||
relay_scoped: false,
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
@@ -824,6 +833,10 @@ impl BrowserManager {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Announce our group FIRST so the relay scopes the getTargets below to our
|
||||
// own tab group (issue #40). On a launched browser this is a no-op.
|
||||
let scoped = self.announce_group().await;
|
||||
|
||||
let page_targets: Vec<TargetInfo> = self.collect_page_targets().await?;
|
||||
|
||||
if page_targets.is_empty() {
|
||||
@@ -870,19 +883,19 @@ impl BrowserManager {
|
||||
self.active_page_index = 0;
|
||||
self.pin_active_target();
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
} else if self.agent_group().is_some() {
|
||||
// STRICT MULTI-AGENT ISOLATION (relay / the user's real Chrome).
|
||||
// `page_targets` here are the USER's and OTHER agents' tabs. A tab
|
||||
// group belongs to exactly ONE agent, so this session must NOT adopt
|
||||
// any of them — it tracks ONLY tabs it creates (its own colored group)
|
||||
// plus popups it opens. Adopting foreign tabs is precisely what let
|
||||
// another concurrent agent's tab churn drop the tab we were driving and
|
||||
// drift eval/click onto the wrong page (multi-agent failure). Open our
|
||||
// own dedicated background tab in the session's group and pin it; the
|
||||
// user's / other agents' tabs stay invisible to us.
|
||||
} else if self.agent_group().is_some() && !scoped {
|
||||
// STRICT MULTI-AGENT ISOLATION fallback (relay, but the group announce
|
||||
// didn't take — e.g. an older relay). Without relay-side scoping,
|
||||
// `page_targets` could be the USER's and OTHER agents' tabs, so this
|
||||
// session must NOT adopt any of them — adopting foreign tabs is what let
|
||||
// another agent's tab churn drop the tab we were driving (multi-agent
|
||||
// failure). Open our own dedicated background tab and pin it instead.
|
||||
self.tab_new(None, None).await?;
|
||||
} else {
|
||||
// A browser WE launched: every tab is ours, so adopt them all.
|
||||
// Either a browser WE launched (every tab is ours) or the relay has
|
||||
// scoped getTargets to our own tab group (#40) — so `page_targets` are
|
||||
// all ours: adopt them (this restores follow-popup + cross-session
|
||||
// adopt under isolation, since foreign tabs were already filtered out).
|
||||
for target in &page_targets {
|
||||
let attach_result: AttachToTargetResult = self
|
||||
.client
|
||||
@@ -1580,7 +1593,11 @@ impl BrowserManager {
|
||||
// in the synthesized targetInfo), so don't adopt anything: the agent drives
|
||||
// only tabs it explicitly created, and pop-ups (e.g. an OAuth/login window)
|
||||
// are the user's. A launched browser (every tab ours) still follows pop-ups.
|
||||
if self.agent_group().is_some() {
|
||||
// Strict isolation only when on the relay WITHOUT group scoping: there a
|
||||
// pop-up can't be told apart from a foreign tab, so adopt nothing. When the
|
||||
// relay IS scoping (#40), getTargets returns only our group, so a tab that
|
||||
// appeared after our own action is genuinely ours (a pop-up) — adopt it.
|
||||
if self.agent_group().is_some() && !self.relay_scoped {
|
||||
return None;
|
||||
}
|
||||
let result: GetTargetsResult = self
|
||||
@@ -1658,16 +1675,17 @@ impl BrowserManager {
|
||||
.collect();
|
||||
let live_ids: HashSet<String> = live.iter().map(|t| t.target_id.clone()).collect();
|
||||
let on_relay = self.agent_group().is_some();
|
||||
// When the relay scopes getTargets to our group (#40), `live` is already
|
||||
// only our own tabs, so adopting unknown ones is safe (a freshly-opened
|
||||
// pop-up). Without scoping, keep strict isolation: never adopt a tab we
|
||||
// didn't create — it belongs to the user or another agent.
|
||||
let strict_isolation = on_relay && !self.relay_scoped;
|
||||
|
||||
for target in &live {
|
||||
if self.update_page_target_info(target) {
|
||||
continue;
|
||||
}
|
||||
// STRICT MULTI-AGENT ISOLATION: on the relay (the user's real Chrome,
|
||||
// shared with other agents), NEVER adopt a tab this session didn't
|
||||
// create — it belongs to the user or another agent's group. Only a
|
||||
// browser we launched (every tab ours) adopts unknown targets.
|
||||
if on_relay {
|
||||
if strict_isolation {
|
||||
continue;
|
||||
}
|
||||
let attach_result: AttachToTargetResult = match self
|
||||
@@ -1837,6 +1855,25 @@ impl BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the relay which tab group this session owns so it can scope
|
||||
/// `Target.getTargets` to us (issue #40). Only meaningful on the relay; a
|
||||
/// no-op (returns false) on a launched/real-CDP connection. Sets and returns
|
||||
/// `relay_scoped`: when true, the daemon can trust getTargets to contain only
|
||||
/// our group and re-enable adopting new tabs (pop-ups, cross-session adopt).
|
||||
async fn announce_group(&mut self) -> bool {
|
||||
let Some(group) = self.agent_group() else {
|
||||
self.relay_scoped = false;
|
||||
return false;
|
||||
};
|
||||
let ok = self
|
||||
.client
|
||||
.send_command_typed::<_, Value>("ABRelay.setGroup", &json!({ "group": group }), None)
|
||||
.await
|
||||
.is_ok();
|
||||
self.relay_scoped = ok;
|
||||
ok
|
||||
}
|
||||
|
||||
pub async fn tab_new(
|
||||
&mut self,
|
||||
url: Option<&str>,
|
||||
@@ -2630,6 +2667,7 @@ async fn initialize_lightpanda_manager(
|
||||
created_targets: HashSet::new(),
|
||||
active_target_id: None,
|
||||
relay_target_misses: HashMap::new(),
|
||||
relay_scoped: false,
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
|
||||
@@ -1529,9 +1529,27 @@ pub async fn get_element_input_value(
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { return typeof this.value === 'string' ? this.value : ''; }"
|
||||
.to_string(),
|
||||
// Read rich-editor content too (issue #41): CodeMirror 5 / Monaco
|
||||
// keep their text in a model, not `.value`; contenteditable keeps
|
||||
// it as innerText. Falls back to `.value` for plain inputs.
|
||||
function_declaration: r#"function() {
|
||||
const el = this;
|
||||
const cm5 = el.closest && el.closest('.CodeMirror');
|
||||
if (cm5 && cm5.CodeMirror) return cm5.CodeMirror.getValue();
|
||||
if (window.monaco && monaco.editor) {
|
||||
try {
|
||||
const eds = monaco.editor.getEditors ? monaco.editor.getEditors() : [];
|
||||
const ed = eds.find(e => e.getDomNode && e.getDomNode().contains(el)) || eds[0];
|
||||
if (ed) return ed.getValue();
|
||||
const m = monaco.editor.getModels ? monaco.editor.getModels() : [];
|
||||
if (m[0]) return m[0].getValue();
|
||||
} catch (e) {}
|
||||
}
|
||||
if (typeof el.value === 'string') return el.value;
|
||||
if (el.isContentEditable) return el.innerText;
|
||||
return '';
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
@@ -1609,7 +1627,15 @@ pub async fn get_element_bounding_box(
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const r = this.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
const inViewport = r.bottom > 0 && r.right > 0
|
||||
&& r.top < (innerHeight || document.documentElement.clientHeight)
|
||||
&& r.left < (innerWidth || document.documentElement.clientWidth);
|
||||
return {
|
||||
x: r.x, y: r.y, width: r.width, height: r.height,
|
||||
centerX: Math.round(r.x + r.width / 2),
|
||||
centerY: Math.round(r.y + r.height / 2),
|
||||
inViewport,
|
||||
};
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
|
||||
@@ -579,7 +579,7 @@ pub async fn fill(
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<String, String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
@@ -590,14 +590,15 @@ pub async fn fill(
|
||||
.await?;
|
||||
|
||||
// Emulate a real edit so framework-controlled inputs (React/Vue) and
|
||||
// site-side listeners actually see the change (issue #25): the old path set
|
||||
// `this.value` directly and used Input.insertText, which left React's
|
||||
// internal value-tracker out of sync and never fired change/blur — so
|
||||
// dependent logic (e.g. Mercari's postal-code → 都道府県 autocomplete) never
|
||||
// ran even though the value was visible. Set the value through the element's
|
||||
// PROTOTYPE setter (which React's _valueTracker hooks), then dispatch
|
||||
// input → change → blur/focusout. `type <sel> <text>` remains for sites that
|
||||
// need per-keystroke events.
|
||||
// site-side listeners actually see the change (issue #25): set the value
|
||||
// through the element's PROTOTYPE setter (which React's _valueTracker hooks),
|
||||
// then dispatch input → change → blur/focusout. Beyond plain inputs, detect
|
||||
// rich editors and use their own API/events (issue #41): CodeMirror 5 and
|
||||
// Monaco have a model that `.value`/`textContent` can't touch; ProseMirror /
|
||||
// contenteditable need `execCommand('insertText')` so beforeinput/input fire
|
||||
// (a raw `textContent =` corrupts PM's doc and skips React composers).
|
||||
// Returns the engine used so the caller can report it. `type <sel> <text>`
|
||||
// remains for sites that need per-keystroke events.
|
||||
let fill_js = format!(
|
||||
r#"function() {{
|
||||
const el = this;
|
||||
@@ -605,13 +606,43 @@ pub async fn fill(
|
||||
try {{ el.focus(); }} catch (e) {{}}
|
||||
const tag = el.tagName;
|
||||
const fire = (type, ctor) => el.dispatchEvent(new (ctor || Event)(type, {{ bubbles: true }}));
|
||||
if (tag === 'SELECT') {{
|
||||
el.value = v; fire('input'); fire('change'); return true;
|
||||
|
||||
// CodeMirror 5: a hidden <textarea> inside .CodeMirror with a live instance.
|
||||
const cm5 = el.closest && el.closest('.CodeMirror');
|
||||
if (cm5 && cm5.CodeMirror) {{ cm5.CodeMirror.setValue(v); return 'codemirror5'; }}
|
||||
|
||||
// Monaco: global `monaco`; prefer the editor whose DOM contains el.
|
||||
if (window.monaco && monaco.editor) {{
|
||||
try {{
|
||||
const eds = monaco.editor.getEditors ? monaco.editor.getEditors() : [];
|
||||
const ed = eds.find(e => e.getDomNode && e.getDomNode().contains(el)) || eds[0];
|
||||
if (ed) {{ ed.setValue(v); return 'monaco'; }}
|
||||
const models = monaco.editor.getModels ? monaco.editor.getModels() : [];
|
||||
if (models[0]) {{ models[0].setValue(v); return 'monaco'; }}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
|
||||
if (tag === 'SELECT') {{ el.value = v; fire('input'); fire('change'); return 'select'; }}
|
||||
|
||||
if (el.isContentEditable) {{
|
||||
el.textContent = v; fire('input', window.InputEvent || Event); fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}} fire('focusout'); return true;
|
||||
// ProseMirror / contenteditable: select-all then insertText fires
|
||||
// beforeinput/input that PM and React composers listen for.
|
||||
let ok = false;
|
||||
try {{
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
ok = document.execCommand('insertText', false, v);
|
||||
}} catch (e) {{}}
|
||||
if (!ok) {{ el.textContent = v; fire('input', window.InputEvent || Event); }}
|
||||
fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}}
|
||||
fire('focusout');
|
||||
return ok ? 'contenteditable' : 'contenteditable-fallback';
|
||||
}}
|
||||
|
||||
const proto = tag === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype;
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
@@ -623,13 +654,13 @@ pub async fn fill(
|
||||
fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}}
|
||||
fire('focusout'); // blur-triggered lookups/validation
|
||||
return true;
|
||||
return 'input';
|
||||
}}"#,
|
||||
val = serde_json::to_string(value).unwrap_or_default()
|
||||
);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: fill_js,
|
||||
@@ -642,7 +673,11 @@ pub async fn fill(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| "input".to_string()))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
+233
-5
@@ -52,6 +52,22 @@ pub struct RelayState {
|
||||
pending: HashMap<i64, (ClientId, Value)>,
|
||||
/// monotonic source of relay-global command ids
|
||||
next_global_id: i64,
|
||||
/// Group-scoped isolation (issue #40). A tab group belongs to exactly one
|
||||
/// agent/session; the relay scopes `Target.getTargets` per client to its own
|
||||
/// group so the daemon can safely adopt new tabs (follow-popup, cross-session
|
||||
/// adopt) without ever seeing the user's or another agent's tabs.
|
||||
///
|
||||
/// clientId -> group name. A client that never announced a group (older
|
||||
/// daemon) is absent here and gets the full, UNSCOPED target list — so this
|
||||
/// is fully backward-compatible.
|
||||
client_groups: HashMap<ClientId, String>,
|
||||
/// targetId -> group name. Created tabs are tagged from `Target.createTarget`'s
|
||||
/// `agentGroup`; an explicitly adopted tab is tagged to the adopter; a pop-up
|
||||
/// inherits its opener's group (needs the extension to report `openerTargetId`).
|
||||
target_group: HashMap<String, String>,
|
||||
/// relay-global id of an in-flight `Target.createTarget` -> the `agentGroup`
|
||||
/// it carried, so the reply's `targetId` can be tagged with that group.
|
||||
pending_create: HashMap<i64, String>,
|
||||
}
|
||||
|
||||
/// What to do with a raw CDP command received from a `CdpClient`.
|
||||
@@ -95,6 +111,7 @@ impl RelayState {
|
||||
/// `pending` entries don't leak.
|
||||
pub fn drop_client(&mut self, client_id: ClientId) {
|
||||
self.pending.retain(|_, (cid, _)| *cid != client_id);
|
||||
self.client_groups.remove(&client_id);
|
||||
}
|
||||
|
||||
/// Route a raw CDP command `{id, method, params?, sessionId?}` from a
|
||||
@@ -123,16 +140,37 @@ impl RelayState {
|
||||
"jsVersion": ""
|
||||
}
|
||||
})),
|
||||
// Non-CDP control message: a daemon announces which tab group
|
||||
// (session) it owns, so getTargets can be scoped to it (issue #40).
|
||||
"ABRelay.setGroup" => {
|
||||
if let Some(g) = params.get("group").and_then(|g| g.as_str()) {
|
||||
if !g.is_empty() {
|
||||
self.client_groups.insert(client_id, g.to_string());
|
||||
}
|
||||
}
|
||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||
}
|
||||
// Discovery is best-effort and event-driven in real CDP; abs only
|
||||
// reads the getTargets result, so an empty ack is enough here.
|
||||
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
|
||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||
}
|
||||
"Target.getTargets" => {
|
||||
// Scope to the client's own group when it announced one; an
|
||||
// un-announced (legacy) client gets the full list (back-compat).
|
||||
let scoped = self.client_groups.get(&client_id).cloned();
|
||||
let infos: Vec<Value> = self
|
||||
.targets
|
||||
.values()
|
||||
.map(|t| t.target_info.clone())
|
||||
.iter()
|
||||
.filter(|(tid, _)| match &scoped {
|
||||
Some(g) => self
|
||||
.target_group
|
||||
.get(*tid)
|
||||
.map(|tg| tg == g)
|
||||
.unwrap_or(false),
|
||||
None => true,
|
||||
})
|
||||
.map(|(_, t)| t.target_info.clone())
|
||||
.collect();
|
||||
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
||||
}
|
||||
@@ -142,9 +180,19 @@ impl RelayState {
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
match self.targets.get(target_id) {
|
||||
Some(entry) => ClientRoute::Local(
|
||||
json!({ "id": id, "result": { "sessionId": entry.session_id } }),
|
||||
),
|
||||
Some(entry) => {
|
||||
let session_id = entry.session_id.clone();
|
||||
// Explicitly adopting a target makes it this client's
|
||||
// (cross-session adopt, #21) — tag it into the adopter's
|
||||
// group so it stays in that client's scoped getTargets and
|
||||
// isn't churn-pruned.
|
||||
if let Some(g) = self.client_groups.get(&client_id).cloned() {
|
||||
self.target_group.insert(target_id.to_string(), g);
|
||||
}
|
||||
ClientRoute::Local(
|
||||
json!({ "id": id, "result": { "sessionId": session_id } }),
|
||||
)
|
||||
}
|
||||
None => ClientRoute::Local(json!({
|
||||
"id": id,
|
||||
"error": { "code": -32602, "message": format!("No such target {target_id}") }
|
||||
@@ -157,6 +205,18 @@ impl RelayState {
|
||||
self.next_global_id += 1;
|
||||
let gid = self.next_global_id;
|
||||
self.pending.insert(gid, (client_id, id));
|
||||
// Remember the group a createTarget carries so the reply's
|
||||
// targetId can be tagged to the creating session (issue #40).
|
||||
if method == "Target.createTarget" {
|
||||
if let Some(g) = params.get("agentGroup").and_then(|g| g.as_str()) {
|
||||
if !g.is_empty() {
|
||||
self.pending_create.insert(gid, g.to_string());
|
||||
self.client_groups
|
||||
.entry(client_id)
|
||||
.or_insert_with(|| g.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientRoute::Forward(json!({
|
||||
"id": gid,
|
||||
"method": "forwardCDPCommand",
|
||||
@@ -201,6 +261,17 @@ impl RelayState {
|
||||
&& msg.get("method").is_none()
|
||||
{
|
||||
let gid = msg.get("id").and_then(|i| i.as_i64());
|
||||
// A createTarget reply: tag the new tab's targetId with the group the
|
||||
// command carried, so it lands in the creating session's scope (#40).
|
||||
if let Some(g) = gid.and_then(|g| self.pending_create.remove(&g)) {
|
||||
if let Some(tid) = msg
|
||||
.get("result")
|
||||
.and_then(|r| r.get("targetId"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
self.target_group.insert(tid.to_string(), g);
|
||||
}
|
||||
}
|
||||
let (to, orig_id) = match gid.and_then(|g| self.pending.remove(&g)) {
|
||||
Some((client_id, orig)) => (Some(client_id), orig),
|
||||
// No mapping (stale/unknown id) — fall back to broadcasting with
|
||||
@@ -241,6 +312,27 @@ impl RelayState {
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// Attribute the tab to a group for scoping (issue #40),
|
||||
// unless we already know it (createTarget tag). An
|
||||
// explicit `abGroup` from the extension wins; otherwise a
|
||||
// pop-up inherits its opener's group via `openerTargetId`.
|
||||
if !self.target_group.contains_key(tid) {
|
||||
if let Some(g) = info
|
||||
.get("abGroup")
|
||||
.and_then(|g| g.as_str())
|
||||
.filter(|g| !g.is_empty())
|
||||
{
|
||||
self.target_group.insert(tid.to_string(), g.to_string());
|
||||
} else if let Some(opener) = info
|
||||
.get("openerTargetId")
|
||||
.and_then(|o| o.as_str())
|
||||
.filter(|o| !o.is_empty())
|
||||
{
|
||||
if let Some(g) = self.target_group.get(opener).cloned() {
|
||||
self.target_group.insert(tid.to_string(), g);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.targets.insert(
|
||||
tid.to_string(),
|
||||
TargetEntry {
|
||||
@@ -255,6 +347,15 @@ impl RelayState {
|
||||
"Target.detachedFromTarget" => {
|
||||
let gone = inner_params.get("sessionId").and_then(|s| s.as_str());
|
||||
if let Some(gone) = gone {
|
||||
let gone_tids: Vec<String> = self
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|(_, e)| e.session_id == gone)
|
||||
.map(|(tid, _)| tid.clone())
|
||||
.collect();
|
||||
for tid in gone_tids {
|
||||
self.target_group.remove(&tid);
|
||||
}
|
||||
self.targets.retain(|_, e| e.session_id != gone);
|
||||
}
|
||||
return vec![];
|
||||
@@ -557,4 +658,131 @@ mod tests {
|
||||
_ => panic!("expected ToExt"),
|
||||
}
|
||||
}
|
||||
|
||||
// === Group-scoped isolation (issue #40) ===
|
||||
|
||||
/// Drive the real create path: announce group, createTarget(agentGroup), feed
|
||||
/// the ext reply (tags target→group) + the attachedToTarget event (creates the
|
||||
/// entry). Returns nothing; mutates `s`.
|
||||
fn create_in_group(s: &mut RelayState, client: ClientId, group: &str, tid: &str, sid: &str) {
|
||||
s.route_client_command(
|
||||
client,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": group } }),
|
||||
);
|
||||
let route = s.route_client_command(
|
||||
client,
|
||||
&json!({ "id": 2, "method": "Target.createTarget",
|
||||
"params": { "url": "about:blank", "agentGroup": group } }),
|
||||
);
|
||||
let gid = match route {
|
||||
ClientRoute::Forward(env) => env["id"].as_i64().unwrap(),
|
||||
_ => panic!("createTarget must forward"),
|
||||
};
|
||||
s.handle_ext_message(&json!({ "id": gid, "result": { "targetId": tid } }), "");
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": sid, "targetInfo": {
|
||||
"targetId": tid, "type": "page", "url": "about:blank", "attached": true } } } }),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
fn get_target_ids(s: &mut RelayState, client: ClientId) -> Vec<String> {
|
||||
match s.route_client_command(client, &json!({ "id": 9, "method": "Target.getTargets" })) {
|
||||
ClientRoute::Local(v) => v["result"]["targetInfos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["targetId"].as_str().unwrap().to_string())
|
||||
.collect(),
|
||||
_ => panic!("getTargets must be local"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_targets_is_scoped_to_each_clients_group() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// Each client sees ONLY its own group's tab — never the other agent's.
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||
assert_eq!(get_target_ids(&mut s, 2), vec!["tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_client_without_group_sees_all_targets() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// Client 3 never announced a group → full, unscoped list (back-compat).
|
||||
let mut all = get_target_ids(&mut s, 3);
|
||||
all.sort();
|
||||
assert_eq!(all, vec!["ta", "tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_inherits_opener_group_and_is_visible_to_that_client_only() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// A pop-up that agent-a's tab opened: extension reports openerTargetId=ta.
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": "sp", "targetInfo": {
|
||||
"targetId": "tp", "type": "page", "url": "https://oauth.example/",
|
||||
"attached": true, "openerTargetId": "ta" } } } }),
|
||||
"",
|
||||
);
|
||||
// Only agent-a sees the pop-up; agent-b never does.
|
||||
let mut a = get_target_ids(&mut s, 1);
|
||||
a.sort();
|
||||
assert_eq!(a, vec!["ta", "tp"]);
|
||||
assert_eq!(get_target_ids(&mut s, 2), vec!["tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_attach_tags_target_into_adopter_group() {
|
||||
let mut s = RelayState::new();
|
||||
// A pre-existing, ungrouped tab the extension reported (e.g. user's tab).
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": "su", "targetInfo": {
|
||||
"targetId": "tu", "type": "page", "url": "https://user.example/", "attached": true } } } }),
|
||||
"",
|
||||
);
|
||||
// Client 1 (group agent-a) explicitly adopts it by targetId (#21).
|
||||
s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": "agent-a" } }),
|
||||
);
|
||||
s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 2, "method": "Target.attachToTarget", "params": { "targetId": "tu" } }),
|
||||
);
|
||||
// Now it's in agent-a's scope and survives the scoped getTargets.
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["tu"]);
|
||||
// A different agent still doesn't see it.
|
||||
s.route_client_command(
|
||||
2,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": "agent-b" } }),
|
||||
);
|
||||
assert!(get_target_ids(&mut s, 2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_clears_target_group() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.detachedFromTarget", "params": { "sessionId": "sa" } } }),
|
||||
"",
|
||||
);
|
||||
assert!(get_target_ids(&mut s, 1).is_empty());
|
||||
assert!(!s.target_group.contains_key("ta"));
|
||||
}
|
||||
}
|
||||
|
||||
+41
-5
@@ -489,6 +489,17 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
println!("y: {}", y);
|
||||
println!("width: {}", w);
|
||||
println!("height: {}", h);
|
||||
if let (Some(cx), Some(cy)) = (
|
||||
obj.get("centerX").and_then(|v| v.as_i64()),
|
||||
obj.get("centerY").and_then(|v| v.as_i64()),
|
||||
) {
|
||||
// Echoed in click-ready CSS px so the agent can paste straight
|
||||
// into `click <centerX> <centerY>` (issue #43).
|
||||
println!("center: {} {}", cx, cy);
|
||||
}
|
||||
if let Some(iv) = obj.get("inViewport").and_then(|v| v.as_bool()) {
|
||||
println!("inViewport: {}", iv);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1490,9 +1501,20 @@ Examples:
|
||||
chrome-use fill - Clear and fill an input field
|
||||
|
||||
Usage: chrome-use fill <selector> <text>
|
||||
chrome-use fill <selector> --file <path>
|
||||
chrome-use fill <selector> --stdin
|
||||
|
||||
Clears the input field and fills it with the specified text.
|
||||
This replaces any existing content in the field.
|
||||
Clears the field and fills it with the text, replacing existing content.
|
||||
Works on rich editors too (issue #41): CodeMirror 5, Monaco, ProseMirror and
|
||||
plain contenteditable are detected and set via their own API / input events,
|
||||
not a raw `.value` write — and the response echoes which `engine` was used.
|
||||
For framework inputs (React/Vue/Angular) the value goes through the native
|
||||
setter so the form registers it (no more "pristine" Save no-ops).
|
||||
|
||||
Options:
|
||||
--file <path> Read the value from a UTF-8 file (large/multiline text,
|
||||
backticks/quotes/newlines/non-ASCII — no shell escaping)
|
||||
--stdin Read the value from stdin
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1501,7 +1523,8 @@ Global Options:
|
||||
Examples:
|
||||
chrome-use fill "#email" "user@example.com"
|
||||
chrome-use fill @e3 "Hello World"
|
||||
chrome-use fill "input[name='search']" "query"
|
||||
chrome-use fill ".CodeMirror" --file ./article.md # set a CodeMirror editor
|
||||
cat post.md | chrome-use fill @e7 --stdin
|
||||
"##
|
||||
}
|
||||
"type" => {
|
||||
@@ -1903,6 +1926,13 @@ Options:
|
||||
--full, -f Capture full page (not just viewport)
|
||||
[selector] Capture just an element (CSS or @ref), e.g. `screenshot ".header" h.png`
|
||||
--clip <x,y,w,h> Capture a pixel region, e.g. `screenshot --clip 0,0,200,40 corner.png`
|
||||
--max-width <px> Downscale so the image's width ≤ px (preserves aspect)
|
||||
--max-height <px> Downscale so the image's height ≤ px
|
||||
--scale <0..1> Downscale by a factor, e.g. 0.5 (DPR-1, so screenshot px
|
||||
line up 1:1 with `click x y`)
|
||||
Default: capped at 2000px longest edge unless overridden
|
||||
(AGENT_BROWSER_SCREENSHOT_MAX_EDGE; 0 disables). Annotated
|
||||
shots are never downscaled, so ref overlays stay aligned.
|
||||
--annotate Overlay numbered labels on interactive elements.
|
||||
Each label [N] corresponds to ref @eN from snapshot.
|
||||
Prints a legend mapping labels to element roles/names.
|
||||
@@ -1925,6 +1955,8 @@ Examples:
|
||||
chrome-use screenshot --full ./full-page.png
|
||||
chrome-use screenshot ".header .indicator" corner.png # just one element
|
||||
chrome-use screenshot --clip 1600,0,200,40 corner.png # a pixel region
|
||||
chrome-use screenshot --scale 0.5 ./half.png # DPR-1: screenshot px == click px
|
||||
chrome-use screenshot --max-width 1400 ./shot.png # cap width for image readers
|
||||
chrome-use screenshot --annotate # Labeled screenshot + legend
|
||||
chrome-use screenshot --annotate ./page.png # Save annotated screenshot
|
||||
chrome-use screenshot --annotate --json # JSON output with annotations
|
||||
@@ -3263,7 +3295,8 @@ Core Commands:
|
||||
click <sel|x y> Click element/@ref, or a viewport coordinate
|
||||
dblclick <sel> Double-click element
|
||||
type <sel> <text> Type into element
|
||||
fill <sel> <text> Clear and fill
|
||||
fill <sel> <text> Clear and fill (handles CodeMirror/Monaco/ProseMirror/
|
||||
contenteditable; `--file <path>`/`--stdin` for large text)
|
||||
press <key> [--hold <ms>] Press key (Enter, Tab, Control+a). --hold keeps it
|
||||
down <ms> then releases — precise (in-daemon), for
|
||||
games/charge: `press d --hold 800`
|
||||
@@ -3283,7 +3316,8 @@ Core Commands:
|
||||
scroll <dir> [px] Scroll (up/down/left/right)
|
||||
scrollintoview <sel> Scroll element into view
|
||||
wait <sel|ms> Wait for element or time
|
||||
screenshot [path] Take screenshot
|
||||
screenshot [path] Take screenshot (auto-downscaled to ≤2000px long edge;
|
||||
--max-width/--max-height/--scale to override)
|
||||
pdf <path> Save as PDF
|
||||
snapshot Accessibility tree with refs (for AI)
|
||||
eval <js> Run JavaScript
|
||||
@@ -3297,6 +3331,8 @@ Navigation:
|
||||
|
||||
Get Info: chrome-use get <what> [selector]
|
||||
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
|
||||
box <sel> → x,y,width,height,centerX,centerY,inViewport in CSS px (feed
|
||||
centerX/centerY into `click x y`); value reads CodeMirror/Monaco too
|
||||
text (no selector = whole page, all frames), text --main, frames (list)
|
||||
|
||||
Check State: chrome-use is <what> <selector>
|
||||
|
||||
@@ -81,6 +81,35 @@ async function groupTabInto(tabId, name) {
|
||||
groupIdByName.set(name, gid)
|
||||
}
|
||||
|
||||
// Group-scoped relay isolation hints (issue #40). The relay scopes
|
||||
// Target.getTargets per agent by tab group; report two things in the synthesized
|
||||
// targetInfo so it can attribute each tab:
|
||||
// - abGroup: the tab's Chrome tab-group TITLE (= the owning session name), so
|
||||
// the relay can re-attribute existing tabs after a restart (createTarget
|
||||
// tagging won't re-run for already-open tabs).
|
||||
// - openerTargetId: the targetId of the tab that opened this one, so a pop-up
|
||||
// (window.open / target=_blank / OAuth result) inherits its opener's group
|
||||
// and the agent that opened it can follow it — without foreign tabs leaking.
|
||||
// Best-effort: any failure yields empty strings, which the relay ignores.
|
||||
async function tabScopeHints(tabId) {
|
||||
let openerTargetId = ''
|
||||
let abGroup = ''
|
||||
try {
|
||||
const t = await chrome.tabs.get(tabId)
|
||||
if (t) {
|
||||
if (typeof t.openerTabId === 'number') {
|
||||
const op = tabs.get(t.openerTabId)
|
||||
if (op) openerTargetId = op.targetId
|
||||
}
|
||||
if (t.groupId != null && t.groupId >= 0 && chrome.tabGroups) {
|
||||
const g = await chrome.tabGroups.get(t.groupId).catch(() => null)
|
||||
if (g && g.title) abGroup = g.title
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return { openerTargetId, abGroup }
|
||||
}
|
||||
|
||||
function postToHost(msg) {
|
||||
try {
|
||||
if (port) port.postMessage(msg)
|
||||
@@ -126,7 +155,7 @@ function connectHost() {
|
||||
} catch {}
|
||||
// Tell the daemon about everything we already have attached, then attach
|
||||
// anything new.
|
||||
reannounceAttachedTabs()
|
||||
void reannounceAttachedTabs()
|
||||
void attachAllTabs()
|
||||
}
|
||||
|
||||
@@ -140,7 +169,7 @@ async function onHostMessage(msg) {
|
||||
// Daemon (re)connected — (re)attach and announce every tab so it discovers
|
||||
// the user's existing tabs rather than racing an empty target list.
|
||||
if (msg.method === 'attachAll') {
|
||||
reannounceAttachedTabs()
|
||||
void reannounceAttachedTabs()
|
||||
await attachAllTabs()
|
||||
return
|
||||
}
|
||||
@@ -387,12 +416,16 @@ async function attachTab(tabId) {
|
||||
sessionToTab.set(sessionId, tabId)
|
||||
rememberSessionTarget(sessionId, targetId)
|
||||
setBadge(tabId, port ? 'on' : 'connecting')
|
||||
const { openerTargetId, abGroup } = await tabScopeHints(tabId)
|
||||
postToHost({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId,
|
||||
method: 'Target.attachedToTarget',
|
||||
params: { sessionId, targetInfo: { ...targetInfo, attached: true } },
|
||||
params: {
|
||||
sessionId,
|
||||
targetInfo: { ...targetInfo, attached: true, openerTargetId, abGroup },
|
||||
},
|
||||
},
|
||||
})
|
||||
return entry
|
||||
@@ -434,14 +467,21 @@ async function attachAllTabs() {
|
||||
}
|
||||
}
|
||||
|
||||
function reannounceAttachedTabs() {
|
||||
for (const [, entry] of tabs.entries()) {
|
||||
async function reannounceAttachedTabs() {
|
||||
for (const [tabId, entry] of tabs.entries()) {
|
||||
// Re-send the group hint too (issue #40) so the relay can rebuild its
|
||||
// targetId→group map after its own restart (createTarget tagging won't
|
||||
// re-run for tabs that are already open).
|
||||
const { openerTargetId, abGroup } = await tabScopeHints(tabId)
|
||||
postToHost({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId: entry.sessionId,
|
||||
method: 'Target.attachedToTarget',
|
||||
params: { sessionId: entry.sessionId, targetInfo: { targetId: entry.targetId, type: 'page', attached: true } },
|
||||
params: {
|
||||
sessionId: entry.sessionId,
|
||||
targetInfo: { targetId: entry.targetId, type: 'page', attached: true, openerTargetId, abGroup },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "chrome-use",
|
||||
"version": "0.4.9",
|
||||
"version": "0.4.10",
|
||||
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||
"icons": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chrome-use",
|
||||
"version": "1.5.20",
|
||||
"version": "1.5.22",
|
||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
@@ -59,6 +59,18 @@ next ref interaction.
|
||||
> anyway.) Driving off pixels on the relay also risks a coordinate event drifting
|
||||
> onto the user's foreground tab — refs never do. See issue #37.
|
||||
|
||||
> **Two different intents — only one is discouraged.** The rule above is about
|
||||
> *screenshot-to-locate* (using a picture to find/hit an element) — that's the bug.
|
||||
> *screenshot-to-capture* — saving a region or element to a file as a **reusable
|
||||
> image asset** (maps, charts, og-images, visual-diff baselines, report figures) —
|
||||
> is fully supported and encouraged: `screenshot [selector] [--clip x,y,w,h] <file>`.
|
||||
> Capturing a rendered map region to a PNG for a blog post is the right tool, not a
|
||||
> smell. Screenshots are auto-downscaled to ≤2000px (longest edge) so they fit an
|
||||
> image reader and their pixels line up with `click x y`; override with
|
||||
> `--max-width`/`--max-height`/`--scale`. To click something you couldn't hit by
|
||||
> ref, `box @ref` gives the element's CSS-px box + `centerX/centerY` to feed
|
||||
> straight into `click <centerX> <centerY>` — no screenshot needed.
|
||||
|
||||
## Before you automate: pick the cheapest tool
|
||||
|
||||
Driving a browser is the heavy option. chrome-use earns its keep when you
|
||||
@@ -373,6 +385,12 @@ foreground, so prefer refs. For below-the-fold content in such a frame, scroll i
|
||||
with `scroll down N --at x,y` (a pixel over the frame) or `--frame n`. For a
|
||||
postal/autocomplete box inside the frame, `type @e "…" --key-events`.
|
||||
|
||||
> **Caveat: `find text "…"` can't reach into a cross-origin iframe** — it errors
|
||||
> "Element not found" even though `snapshot -i` lists those nodes and
|
||||
> `get text` reads them. Inside cross-origin iframes, target elements by their
|
||||
> **snapshot `@ref`**, not by `find`. (`box @ref` also works on iframe refs when
|
||||
> you need a coordinate fallback.)
|
||||
|
||||
### When refs don't work or you don't want to snapshot
|
||||
|
||||
Use semantic locators:
|
||||
|
||||
Reference in New Issue
Block a user