Skip to content
Merged
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
22 changes: 22 additions & 0 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,27 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// `args`, and emit a `site` action: the daemon navigates to the
// adapter's @meta.domain (reusing the tab if already there) and evals
// the adapter function in the site's own logged-in page.
// `site analyze [url]`: scan the page for an adapter's data source.
if rest.first() == Some(&"analyze") {
let mut cmd = json!({ "id": id, "action": "site_analyze" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
return Ok(cmd);
}
// `site verify <name>/<cmd> [args] [--write-fixture]`: a normal run,
// then main.rs compares the result's shape with the stored fixture.
let (verify, rest): (Option<bool>, Vec<&str>) = if rest.first() == Some(&"verify") {
let write = rest.contains(&"--write-fixture");
let kept = rest[1..]
.iter()
.filter(|a| **a != "--write-fixture")
.copied()
.collect();
(Some(write), kept)
} else {
(None, rest.to_vec())
};
let spec = rest.first().ok_or(ParseError::InvalidValue {
message: "site requires <name>/<command> (run `chrome-use site list`)".to_string(),
usage: "site <name>/<command> [args]",
Expand Down Expand Up @@ -2000,6 +2021,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// The CLI's socket read waits timeout_ms + a margin, so a long run
// is not cut off client-side before the daemon answers.
"timeout_ms": run_timeout_ms + 10_000,
"verify": verify.map(|write| json!({ "spec": spec, "writeFixture": write })),
}))
}

Expand Down
150 changes: 147 additions & 3 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod install;
mod jev;
mod mcp;
mod native;
mod opencli;
mod output;
mod ownership;
mod read;
Expand All @@ -29,7 +30,7 @@ mod test_utils;
mod upgrade;
mod validation;

use serde_json::json;
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::process::exit;
Expand Down Expand Up @@ -1611,6 +1612,81 @@ fn main() {
),
}
}
// A `name/cmd` we have no adapter for but OpenCLI does: run it with
// OpenCLI's runtime over this session (opencli.rs). `site verify` too.
{
let verify = clean.get(1).map(|s| s.as_str()) == Some("verify");
let at = if verify { 2 } else { 1 };
if let Some(spec) = clean.get(at).filter(|s| opencli::handles(s)) {
let entry = opencli::lookup(spec).unwrap_or_default();
let write_fixture = verify && clean.iter().any(|a| a == "--write-fixture");
let rest: Vec<String> = clean[at + 1..]
.iter()
.filter(|a| !(verify && a.as_str() == "--write-fixture"))
.cloned()
.collect();
let mut env = opencli::run(spec, &entry, &rest, &flags.session);
let mut ok = env.get("success").and_then(|v| v.as_bool()) == Some(true);
let result = env.get("data").cloned().unwrap_or(Value::Null);
if ok && verify {
let (vok, report) = site::verify_result(spec, &result, write_fixture);
if !vok {
ok = false;
let issues: Vec<String> = report
.get("issues")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|i| i.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
env["error"] = json!(format!("site verify {spec}: {}", issues.join("; ")));
}
env["verify"] = report;
}
if flags.json {
let mut data = json!({ "result": result, "source": opencli::SOURCE_LABEL });
if let Some(v) = env.get("verify") {
data["verify"] = v.clone();
}
println!(
"{}",
json!({ "success": ok, "data": data, "error": if ok { Value::Null } else { env.get("error").cloned().unwrap_or(Value::Null) } })
);
} else if ok {
eprintln!("{}", color::dim(&format!("site {spec} (via OpenCLI)")));
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
if let Some(v) = env.get("verify") {
if v.get("recorded").and_then(|x| x.as_bool()) == Some(true) {
eprintln!(
"{} site verify: fixture recorded",
color::success_indicator()
);
} else {
eprintln!("{} site verify: ok", color::success_indicator());
}
}
} else {
let err = env
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("failed");
match env
.get("hint")
.and_then(|v| v.as_str())
.filter(|h| !h.is_empty())
{
Some(h) => eprintln!("{} {err} — {h}", color::error_indicator()),
None => eprintln!("{} {err}", color::error_indicator()),
}
}
exit(if ok { 0 } else { 1 });
}
}
match clean.get(1).map(|s| s.as_str()) {
Some("update") => {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
Expand All @@ -1631,9 +1707,23 @@ fn main() {
return;
}
Some("list") => {
let theirs: Vec<String> = {
let ours = site::list_adapters().unwrap_or_default();
let mut v: Vec<String> = opencli::manifest()
.iter()
.filter_map(opencli::spec_of)
.filter(|s| !ours.contains(s))
.collect();
v.sort();
v.dedup();
v
};
match site::list_adapters() {
Ok(list) if flags.json => {
println!("{}", json!({ "success": true, "adapters": list }))
println!(
"{}",
json!({ "success": true, "adapters": list, "opencli": theirs })
)
}
Ok(list) if list.is_empty() => {
println!("no site adapters installed — run `chrome-use site update`")
Expand All @@ -1642,6 +1732,9 @@ fn main() {
for a in &list {
println!("{a}");
}
for a in &theirs {
println!("{a} {}", color::dim("(opencli)"));
}
eprintln!(
"{}",
color::dim(&format!(
Expand All @@ -1659,6 +1752,16 @@ fn main() {
}
Some("info") => {
let spec = clean.get(2).cloned().unwrap_or_default();
if opencli::handles(&spec) {
if let Some(entry) = opencli::lookup(&spec) {
println!(
"{}",
serde_json::to_string_pretty(&opencli::info(&entry))
.unwrap_or_default()
);
return;
}
}
match site::load_adapter(&spec) {
Ok(a) => println!(
"{}",
Expand Down Expand Up @@ -1781,6 +1884,9 @@ fn main() {
}
return;
}
// `site analyze [url]` / `site verify <name>/<cmd> …` → daemon dispatch
// (commands.rs builds them).
Some("analyze") | Some("verify") => {}
// `site <name>/<cmd> [args]` → fall through to the daemon dispatch.
Some(spec) if spec.contains('/') => {
// #125: an adapter arg whose name collides with a reserved global
Expand Down Expand Up @@ -1815,7 +1921,7 @@ fn main() {
}
_ => {
eprintln!(
"{} usage: chrome-use site <name>/<cmd> [args] | site update | site list | \
"{} usage: chrome-use site <name>/<cmd> [args] | site analyze [url] | site verify <name>/<cmd> [args] [--write-fixture] | site update | site list | \
site info <name>/<cmd> | site sources | site add|remove <source>",
color::error_indicator()
);
Expand Down Expand Up @@ -3106,6 +3212,44 @@ fn main() {
}
}
}
// `site verify`: compare the result's shape with the stored fixture
// (or record it). A mismatch fails the command like an adapter error.
if let Some(v) = cmd.get("verify").filter(|v| !v.is_null()) {
if resp.success {
let spec = v.get("spec").and_then(|x| x.as_str()).unwrap_or("");
let write = v.get("writeFixture").and_then(|x| x.as_bool()) == Some(true);
let result = resp
.data
.as_ref()
.and_then(|d| d.get("result"))
.cloned()
.unwrap_or(Value::Null);
let (ok, report) = site::verify_result(spec, &result, write);
if !ok {
resp.success = false;
let issues: Vec<String> = report
.get("issues")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|i| i.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
resp.error = Some(format!(
"site verify {spec}: {}",
if issues.is_empty() {
"could not record the fixture".to_string()
} else {
issues.join("; ")
}
));
}
if let Some(d) = resp.data.as_mut().and_then(|d| d.as_object_mut()) {
d.insert("verify".into(), report);
}
}
}
if let Some(err) = resp.error.as_mut() {
if err.contains("has NO snapshot refs") {
// Keep what to do last: agents read errors through `tail -1`.
Expand Down
32 changes: 32 additions & 0 deletions cli/src/native/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2012,6 +2012,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"content" => handle_content(state).await,
"evaluate" => handle_evaluate(cmd, state).await,
"site" => handle_site(cmd, state).await,
"site_analyze" => handle_site_analyze(cmd, state).await,
"script" => super::script::handle_script(cmd, state).await,
"close" => handle_close(state).await,
"keep" => handle_keep(cmd, state).await,
Expand Down Expand Up @@ -4794,6 +4795,37 @@ async fn resolve_iframe_selector(
/// already loaded the adapter and built the `script`; here we just place the page
/// and evaluate. Never disrupts the user's foreground tab — navigation happens on
/// the daemon's own tab (same as every other command on the relay).
/// `site analyze [url]`: scan the current page (after opening `url`, if given)
/// for what an adapter should read — API calls the page made, state it embeds,
/// anti-bot vendors — and recommend a data source.
async fn handle_site_analyze(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
if cmd.get("url").and_then(|v| v.as_str()).is_some() {
handle_navigate(cmd, state).await?;
}
let mgr = state.browser.as_ref().ok_or("site analyze: no browser")?;
let raw = mgr.evaluate(crate::site::ANALYZE_JS, None).await?;
let strings = |key: &str| -> Vec<String> {
raw.get("signals")
.and_then(|s| s.get(key))
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
};
let signals = humanize::DetectSignals {
cookie_names: strings("cookies"),
script_urls: strings("scripts"),
window_globals: strings("globals"),
};
let vendors = humanize::detected_vendors(&signals);
let host = raw.get("host").and_then(|v| v.as_str()).unwrap_or("");
let adapters = crate::site::adapters_for_domain(host);
Ok(crate::site::analyze_report(&raw, &vendors, &adapters))
}

async fn handle_site(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let domain = cmd
.get("domain")
Expand Down
19 changes: 19 additions & 0 deletions cli/src/native/humanize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,25 @@ const VENDOR_MARKERS: &[(&str, &str)] = &[
("__cf_bm", "cloudflare-bot-mgmt"),
];

/// The anti-bot vendors whose markers appear in `signals`, deduped, in marker
/// order (for `site analyze`).
pub fn detected_vendors(signals: &DetectSignals) -> Vec<&'static str> {
let hay: Vec<String> = signals
.cookie_names
.iter()
.chain(signals.script_urls.iter())
.chain(signals.window_globals.iter())
.map(|s| s.to_ascii_lowercase())
.collect();
let mut out: Vec<&'static str> = Vec::new();
for (marker, vendor) in VENDOR_MARKERS {
if hay.iter().any(|h| h.contains(marker)) && !out.contains(vendor) {
out.push(vendor);
}
}
out
}

/// Decide the level for a page. Returns `Human` if any known anti-bot vendor is
/// present, otherwise `baseline`. Misses just stay at baseline and false hits
/// only cost a little latency, so matching is deliberately liberal.
Expand Down
Loading
Loading