Compare commits

..
Author SHA1 Message Date
leeguooooo 6e50f0ecab chore(release): 0.27.0-fork.50 — tab-title truncation + multi-agent/eval/type docs
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-11 23:55:01 +09:00
leeguooooo 6f71f4e1ff fix: truncate tab-list title too; doc raw --cdp isolation limit + eval/type notes
From Hermes's fork.49 re-dogfood (9/11 fixes confirmed PASS):
- tab list: a page can set its title to a multi-KB string (= a giant URL); cap
  the title column like the URL so the row stays readable.
- skill: clarify that true multi-agent isolation needs the extension-connect path
  (per-session tab groups) — raw `--cdp` shares the browser, so a sibling
  session's `open` can navigate your tab. Use the extension for concurrent agents.
- skill: prefer `eval --json` for array/object results (plain render is
  multi-line / pipe-hostile); note type/fill don't fire keydown (use `keyboard
  type` when key events are required).

(Hermes's "find-text click bypasses humanize" was a false alarm — verified both
paths curve; the apparent 1-vs-12 was cursor continuity on the same target.)
2026-06-11 23:55:00 +09:00
leeguooooo 31ef0d7e6a chore(release): 0.27.0-fork.49 — embed stealth-status + multi-agent skill guidance
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-11 23:36:14 +09:00
leeguooooo 42560b56fc docs(skill): concurrent agents must use distinct --session (issue #6)
Within a session, commands are pinned to the agent's opened tab (fork.47). But
two agents on the same (default) session share one daemon + active tab and
clobber each other. Document that each concurrent agent must use a unique
--session — which gives it its own isolated tab group on the shared real Chrome.
2026-06-11 23:35:03 +09:00
leeguooooo 0c7534d9b2 chore(release): 0.27.0-fork.48 — iframe-proxy toggle (#4) + stealth status (#5)
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-11 23:33:26 +09:00
leeguooooo ad4fb14ed9 feat: stealth status self-check command (issue #5)
Local stealth verification with no external detector: reports mode (connect vs
launch), live fingerprint probes (navigator.webdriver / window.chrome / plugins /
UA-headless) as pass/fail, and an audit of the active overrides for the path
(incl. the iframe-proxy state from #4). `--json` for a stable shape agents can
gate a sensitive flow on. Distinct from `doctor` (install/env health).
2026-06-11 23:33:24 +09:00
leeguooooo a976287f03 fix(stealth): AGENT_BROWSER_DISABLE_IFRAME_PROXY for a clean 0% CreepJS (issue #4)
--launch mode scored ~20% stealth on CreepJS because the srcdoc-iframe
contentWindow Proxy trips `hasIframeProxy` — the proxy that hides automation is
itself a fingerprintable tell (violates this fork's own "native > JS lies" rule).
Add a config-driven opt-out (no detectable global): AGENT_BROWSER_DISABLE_IFRAME_PROXY=1
drops the patch via __abStealth.disableIframeProxy → the iframe IIFE early-returns
→ clean 0% CreepJS, trading the niche srcdoc-iframe masking. Default keeps current
behavior. README now documents the --launch 20% honestly and scopes the headline
0% to the extension-connect path. Verified: launch + srcdoc page intact with the
toggle; stealth tests green (config strip-prefix kept in sync).
2026-06-11 23:25:24 +09:00
10 changed files with 179 additions and 7 deletions
+1 -1
View File
@@ -225,7 +225,7 @@ When connected to your real Chrome, we inject **zero** JavaScript patches. Your
`0% stealth` on CreepJS is the key number: because the connect path patches **nothing**, there is no override for a lie-detector to catch. (Dashboards that read `navigator.languages` order or IP geolocation may show a soft "navigator"/"location" flag — that tracks *your real Chrome's* language list and network, not an automation tell.)
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it passes the suite above — with one caveat: CreepJS reports **~20% stealth** because the srcdoc-iframe `contentWindow` patch trips its `hasIframeProxy` probe (the proxy that hides automation is itself a tell). Everything else is clean (`0% headless`, sannysoft/browserscan green, Cloudflare passed). Set **`AGENT_BROWSER_DISABLE_IFRAME_PROXY=1`** to drop that patch for a clean **0% stealth** (trades the niche srcdoc-iframe masking). The **extension-connect path** (your real Chrome) injects zero JS and is unaffected — it's the genuine 0% path.
### Human-like input (behavioural stealth)
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.47"
version = "0.27.0-fork.50"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.47"
version = "0.27.0-fork.50"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+9
View File
@@ -931,6 +931,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
}
// === Stealth self-check ===
"stealth" => {
// `stealth [status]` — local stealth self-check: mode, live probes
// (navigator.webdriver, window.chrome, plugins, UA), and the list of
// active overrides. --json for a stable machine-readable shape.
// (Distinct from `doctor`, which checks install/env/Chrome health.)
Ok(json!({ "id": id, "action": "stealth_status" }))
}
// === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
+84
View File
@@ -1316,6 +1316,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"content" => handle_content(state).await,
"evaluate" => handle_evaluate(cmd, state).await,
"close" => handle_close(state).await,
"stealth_status" => handle_stealth_status(state).await,
"snapshot" => handle_snapshot(cmd, state).await,
"screenshot" => handle_screenshot(cmd, state).await,
"click" => handle_click(cmd, state).await,
@@ -2680,6 +2681,89 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
Ok(json!({ "result": result, "origin": url }))
}
/// Local stealth self-check: reports the active mode, live fingerprint probes,
/// and the list of applied overrides — so an agent (or human) can confirm
/// stealth is working without driving an external detector, and audit exactly
/// what's patched on this path (issue #5).
async fn handle_stealth_status(state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let connect = mgr.is_cdp_connection();
let probe_js = r#"(() => {
const ua = navigator.userAgent || '';
return {
webdriver: navigator.webdriver === true,
hasWindowChrome: typeof window.chrome === 'object' && window.chrome !== null,
plugins: navigator.plugins ? navigator.plugins.length : 0,
languages: navigator.languages || [],
platform: navigator.platform || '',
headlessUA: /Headless/i.test(ua),
};
})()"#;
let p = mgr.evaluate(probe_js, None).await.unwrap_or(Value::Null);
let webdriver = p.get("webdriver").and_then(|v| v.as_bool()).unwrap_or(true);
let has_chrome = p
.get("hasWindowChrome")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let plugins = p.get("plugins").and_then(|v| v.as_u64()).unwrap_or(0);
let headless_ua = p
.get("headlessUA")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let checks = json!([
{ "name": "navigator.webdriver is false", "pass": !webdriver },
{ "name": "window.chrome present", "pass": has_chrome },
{ "name": "navigator.plugins non-empty", "pass": plugins > 0, "value": plugins },
{ "name": "userAgent has no 'Headless'", "pass": !headless_ua },
]);
let ok = !webdriver && has_chrome && plugins > 0 && !headless_ua;
let overrides = if connect {
json!([
"navigator.webdriver=false via Emulation.setAutomationOverride (native CDP — no JS lie)",
"Runtime.enable OFF unless console/error capture is opted in (no rebrowser runtime leak)",
"zero JS patches injected — the browser's real fingerprint is used as-is",
])
} else {
let iframe_proxy =
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY").as_deref() != Ok("1");
json!([
"navigator.webdriver removed; navigator.languages/locale normalized",
"window.chrome / chrome.runtime shimmed; navigator.platform fixed",
"WebGL vendor/renderer, plugins, permissions normalized",
format!(
"srcdoc-iframe contentWindow proxy: {} (CreepJS hasIframeProxy)",
if iframe_proxy {
"ON — set AGENT_BROWSER_DISABLE_IFRAME_PROXY=1 for clean 0%"
} else {
"off"
}
),
format!(
"canvas/audio noise: {} (AGENT_BROWSER_HIDE_CANVAS)",
if std::env::var("AGENT_BROWSER_HIDE_CANVAS").as_deref() == Ok("1") {
"on"
} else {
"off (opt-in)"
}
),
"Chrome flags: --disable-blink-features=AutomationControlled, ANGLE GL",
])
};
Ok(json!({
"stealthStatus": {
"mode": if connect { "connect (your real Chrome — strongest)" } else { "launch (standalone)" },
"ok": ok,
"checks": checks,
"overrides": overrides,
"probe": p,
}
}))
}
async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
if let Some(ref mgr) = state.browser {
if let Some(ref session_name) = state.session_name {
+15 -2
View File
@@ -51,18 +51,19 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
vec![locale, base_lang]
};
let config_line = format!(
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {}, disableIframeProxy: {} }};"#,
locale,
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
hide_canvas_enabled(),
canvas_noise_seed(),
disable_iframe_proxy_enabled(),
);
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
// otherwise the fallback below prepends a SECOND `const __abStealth`
// declaration and the whole script dies with a redeclaration SyntaxError.
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };"#,
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };"#,
) {
format!("{}{}", config_line, rest)
} else {
@@ -81,6 +82,18 @@ fn hide_canvas_enabled() -> bool {
.unwrap_or(false)
}
/// Whether to DROP the srcdoc-iframe `contentWindow` Proxy patch (FullLaunch).
/// That patch masks automation in srcdoc iframes, but the JS `Proxy` is itself a
/// fingerprintable tell (CreepJS `hasIframeProxy` → ~20% stealth). Off by default
/// (keep the patch); `AGENT_BROWSER_DISABLE_IFRAME_PROXY=1` drops it for a clean
/// 0% CreepJS at the cost of that niche srcdoc-iframe masking.
fn disable_iframe_proxy_enabled() -> bool {
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY")
.ok()
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
/// device returns the same hash on repeated reads) but differs from the
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
+5 -1
View File
@@ -1,4 +1,4 @@
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };
// Redefine a navigator property on its PROTOTYPE (Navigator / WorkerNavigator),
// the way real Chrome exposes these — as prototype getters, NOT instance own
// properties. Adding an own property to the `navigator` instance is itself a
@@ -289,6 +289,10 @@ const __abRedefineNavProto = (name, getterImpl) => {
})();
(function(){
if (typeof document === 'undefined' || typeof document.createElement !== 'function') return;
// The srcdoc-iframe contentWindow Proxy below is itself a fingerprintable tell
// (CreepJS `hasIframeProxy`). Honor the opt-out so callers can trade the niche
// srcdoc masking for a clean 0% CreepJS fingerprint.
if (typeof __abStealth !== 'undefined' && __abStealth.disableIframeProxy) return;
const nativeCreateElement = document.createElement.bind(document);
const nativeSrcdocDescriptor =
typeof HTMLIFrameElement !== 'undefined'
+38
View File
@@ -352,6 +352,39 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
println!("{}", enabled);
return;
}
// Stealth self-check (`stealth status` / `doctor`)
if let Some(s) = data.get("stealthStatus") {
let ok = s.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
let mode = s.get("mode").and_then(|v| v.as_str()).unwrap_or("?");
println!(
"{} stealth: {} · mode: {}",
if ok {
color::success_indicator().to_string()
} else {
color::cyan("")
},
if ok {
"all checks pass"
} else {
"some checks need attention"
},
mode
);
if let Some(checks) = s.get("checks").and_then(|v| v.as_array()) {
for c in checks {
let pass = c.get("pass").and_then(|v| v.as_bool()).unwrap_or(false);
let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
println!(" {} {}", if pass { "" } else { "" }, name);
}
}
if let Some(ovs) = s.get("overrides").and_then(|v| v.as_array()) {
println!(" applied overrides:");
for o in ovs.iter().filter_map(|v| v.as_str()) {
println!(" {}", color::dim(&format!("· {o}")));
}
}
return;
}
if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) {
println!("{}", checked);
return;
@@ -445,6 +478,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled");
// A page can set its title to a multi-KB string (e.g. equal to a
// giant JWT/OTP URL); truncate it like the URL so the row stays
// readable.
let title = truncate_middle(title, 120);
let title = title.as_str();
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
// the list stays readable instead of flooding the terminal.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.47",
"version": "0.27.0-fork.50",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
+24
View File
@@ -408,6 +408,13 @@ top-level `const x`/`let x`/`var x` in one call collides with the next
names, assign to `window.x`, or wrap the body in an IIFE
(`(() => { const x = …; return x; })()`).
**For array/object results, use `eval --json`** — the plain renderer
pretty-prints across multiple lines, which `tail`/`head`/pipes mangle; `--json`
emits one parseable line. Also note **`type`/`fill` insert text without firing
`keydown`/`keyup`** (CDP insertText) — the value lands, but a page that gates on
key events (some search-as-you-type widgets) won't react; use `keyboard type` (or
`press` per key) when real keystrokes are required.
### Screenshot
```bash
@@ -451,6 +458,19 @@ agent-browser --session b fill @e1 "bob@test.com"
`AGENT_BROWSER_SESSION=myapp` sets the default session for the current
shell.
**Concurrent agents MUST each use a distinct `--session <name>`.** Within one
session, commands are pinned to the tab you opened (by target_id, so a foreign
tab can't drift your `eval`/`screenshot`). Two agents sharing the *same* session
(e.g. both on the bare default) share one daemon and one active tab and will
clobber each other.
True multi-agent isolation requires the **extension-connect path**: each
`--session` gets its own colored Chrome tab group, so sessions never touch each
other's tabs. **Raw `--cdp <port>` does NOT isolate** — every session attaches to
the same browser's existing targets, so a second session's first `open` can
navigate a sibling's tab. For concurrent agents on one real Chrome, use the
extension (each with a distinct `--session`), not raw `--cdp`.
### Mock network requests
```bash
@@ -520,6 +540,10 @@ agent-browser doctor # full diagnosis (env, Chrome, daemons,
agent-browser doctor --offline --quick # fast, local-only
agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
agent-browser doctor --json # structured output for programmatic consumption
agent-browser stealth status # stealth self-check: mode + live probes
agent-browser stealth status --json # (webdriver/chrome/plugins/UA) + applied
# overrides. Gate a sensitive flow on this
# instead of driving an external detector.
```
`doctor` auto-cleans stale socket/pid/version sidecar files on every run.