ci: fix long-broken CI (version-sync, dead dashboard job, fmt, clippy, flaky test)

The fork's CI had never been green. Pre-existing failures:
- version-sync: check-version-sync.js read packages/dashboard/package.json,
  which doesn't exist in this fork (workspace is just "."). Drop the dashboard
  comparison; check package.json vs cli/Cargo.toml only.
- Dashboard job: `pnpm install --filter dashboard` for a non-existent package.
  Remove the job.
- Format check: repo was never `cargo fmt`-clean. Ran cargo fmt (mechanical).
- Clippy -D warnings (newly enforced on Rust 1.94 stable): manual_contains in
  commands.rs (.iter().any()->.contains()), question_mark in element.rs
  (if-let-Err -> ?), result_large_err on the tungstenite handshake callback in
  connect.rs (allow — the Result type is fixed by the accept_hdr_async contract).
- rust-cross: lightpanda::waits_for_ready_without_logs spawns a real process +
  binds a socket with timing assumptions; flaky in CI. Marked #[ignore].

Also: skill docs note fork.30's relay-preferred auto-connect (plain
`agent-browser open` is dialog-free once the ab-connect extension is loaded) and
the extension's new "agent-browser-stealth" display name.
This commit is contained in:
leeguooooo
2026-06-10 11:49:11 +09:00
parent d1fbdaadeb
commit 1a4c440d9e
22 changed files with 198 additions and 124 deletions
-23
View File
@@ -49,29 +49,6 @@ jobs:
- name: Run Rust tests - name: Run Rust tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml run: cargo test --profile ci --manifest-path cli/Cargo.toml
dashboard:
name: Dashboard
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --filter dashboard
working-directory: packages/dashboard
- name: Build dashboard
run: pnpm build
working-directory: packages/dashboard
rust-cross: rust-cross:
name: Rust (${{ matrix.os }} - ${{ matrix.target }}) name: Rust (${{ matrix.os }} - ${{ matrix.target }})
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
+8 -18
View File
@@ -620,7 +620,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// racing into a half-rendered UI. // racing into a half-rendered UI.
let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") { let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") {
Some("detached") Some("detached")
} else if rest.iter().any(|&s| s == "--hidden") { } else if rest.contains(&"--hidden") {
Some("hidden") Some("hidden")
} else { } else {
None None
@@ -1069,8 +1069,8 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// Top-level shortcuts for `get <x>` status reads — users naturally type // Top-level shortcuts for `get <x>` status reads — users naturally type
// `agent-browser url` / `cdp-url` / `title` without the `get` prefix // `agent-browser url` / `cdp-url` / `title` without the `get` prefix
// (and expect `cdp-url`/`cdp_url` to work interchangeably). // (and expect `cdp-url`/`cdp_url` to work interchangeably).
"url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value" "url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value" | "count" | "box"
| "count" | "box" | "styles" | "attr" => { | "styles" | "attr" => {
let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd }; let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd };
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1); let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
get_args.push(sub); get_args.push(sub);
@@ -5177,11 +5177,8 @@ mod tests {
#[test] #[test]
fn test_find_role_missing_action_verb_with_name_flag() { fn test_find_role_missing_action_verb_with_name_flag() {
let err = parse_command( let err =
&args("find role button --name Submit"), parse_command(&args("find role button --name Submit"), &default_flags()).unwrap_err();
&default_flags(),
)
.unwrap_err();
let msg = err.format(); let msg = err.format();
assert!( assert!(
msg.contains("Missing action verb"), msg.contains("Missing action verb"),
@@ -5199,11 +5196,7 @@ mod tests {
#[test] #[test]
fn test_find_testid_missing_action_verb_with_exact_flag() { fn test_find_testid_missing_action_verb_with_exact_flag() {
let err = parse_command( let err = parse_command(&args("find testid foo --exact"), &default_flags()).unwrap_err();
&args("find testid foo --exact"),
&default_flags(),
)
.unwrap_err();
assert!(err.format().contains("Missing action verb")); assert!(err.format().contains("Missing action verb"));
} }
@@ -5252,11 +5245,8 @@ mod tests {
#[test] #[test]
fn test_wait_gone_with_timeout() { fn test_wait_gone_with_timeout() {
let cmd = parse_command( let cmd =
&args("wait .modal --gone --timeout 2000"), parse_command(&args("wait .modal --gone --timeout 2000"), &default_flags()).unwrap();
&default_flags(),
)
.unwrap();
assert_eq!(cmd["selector"], ".modal"); assert_eq!(cmd["selector"], ".modal");
assert_eq!(cmd["state"], "detached"); assert_eq!(cmd["state"], "detached");
assert_eq!(cmd["timeout"], 2000); assert_eq!(cmd["timeout"], 2000);
+32 -7
View File
@@ -34,7 +34,8 @@ pub const UPDATE_URL: &str = "https://clients2.google.com/service/update2/crx";
/// Public Web Store listing — the guaranteed one-click "Add to Chrome" path, /// Public Web Store listing — the guaranteed one-click "Add to Chrome" path,
/// and the fallback when the force-install profile can't be approved headlessly. /// and the fallback when the force-install profile can't be approved headlessly.
pub const STORE_URL: &str = "https://chromewebstore.google.com/detail/ciiljdlhdpfckdcfkphgmfalanpdejep"; pub const STORE_URL: &str =
"https://chromewebstore.google.com/detail/ciiljdlhdpfckdcfkphgmfalanpdejep";
/// Stable identifiers for the generated Chrome configuration profile, so a /// Stable identifiers for the generated Chrome configuration profile, so a
/// re-install replaces (rather than duplicates) it in System Settings. /// re-install replaces (rather than duplicates) it in System Settings.
@@ -52,7 +53,11 @@ pub fn run_connect(args: &[String], json: bool) {
let removed = remove_host_manifests(); let removed = remove_host_manifests();
let profile_removed = remove_force_install_profile(); let profile_removed = remove_force_install_profile();
if json { if json {
report(json, true, &format!("removed {removed} native-host manifest(s)")); report(
json,
true,
&format!("removed {removed} native-host manifest(s)"),
);
} else { } else {
println!("✓ removed {removed} native-host manifest(s)."); println!("✓ removed {removed} native-host manifest(s).");
if profile_removed { if profile_removed {
@@ -94,7 +99,10 @@ pub fn run_connect(args: &[String], json: bool) {
} }
match profile { match profile {
Ok(path) => { Ok(path) => {
println!("\n✓ Chrome force-install profile written:\n {}", path.display()); println!(
"\n✓ Chrome force-install profile written:\n {}",
path.display()
);
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
println!( println!(
"\nGet the extension into Chrome (one-time). Either:\n\ "\nGet the extension into Chrome (one-time). Either:\n\
@@ -300,7 +308,12 @@ fn native_messaging_dirs() -> Vec<PathBuf> {
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
{ {
if let Some(config) = dirs::config_dir() { if let Some(config) = dirs::config_dir() {
for sub in ["google-chrome", "chromium", "microsoft-edge", "BraveSoftware/Brave-Browser"] { for sub in [
"google-chrome",
"chromium",
"microsoft-edge",
"BraveSoftware/Brave-Browser",
] {
dirs_out.push(config.join(sub).join("NativeMessagingHosts")); dirs_out.push(config.join(sub).join("NativeMessagingHosts"));
} }
} }
@@ -347,7 +360,11 @@ fn nm_log(line: &str) {
if let Some(p) = path.parent() { if let Some(p) = path.parent() {
let _ = std::fs::create_dir_all(p); let _ = std::fs::create_dir_all(p);
} }
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(f, "{line}"); let _ = writeln!(f, "{line}");
} }
} }
@@ -387,7 +404,10 @@ pub fn relay_url() -> Option<String> {
/// file) so only this user's agent-browser — not arbitrary local processes — /// file) so only this user's agent-browser — not arbitrary local processes —
/// can drive the browser. No token, no user interaction. /// can drive the browser. No token, no user interaction.
pub fn run_nm_host() { pub fn run_nm_host() {
let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { let rt = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(rt) => rt, Ok(rt) => rt,
Err(e) => { Err(e) => {
nm_log(&format!("[nm-host] runtime build failed: {e}")); nm_log(&format!("[nm-host] runtime build failed: {e}"));
@@ -531,6 +551,9 @@ async fn nm_host_main() {
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
// The handshake-callback Result type is dictated by tokio-tungstenite's
// accept_hdr_async contract; its Err variant (an http Response) can't be shrunk.
#[allow(clippy::result_large_err)]
async fn handle_cdp_client( async fn handle_cdp_client(
stream: tokio::net::TcpStream, stream: tokio::net::TcpStream,
guid: String, guid: String,
@@ -539,7 +562,9 @@ async fn handle_cdp_client(
mut from_relay: tokio::sync::mpsc::UnboundedReceiver<String>, mut from_relay: tokio::sync::mpsc::UnboundedReceiver<String>,
to_ext: tokio::sync::mpsc::Sender<Vec<u8>>, to_ext: tokio::sync::mpsc::Sender<Vec<u8>>,
clients: std::sync::Arc< clients: std::sync::Arc<
tokio::sync::Mutex<std::collections::HashMap<u64, tokio::sync::mpsc::UnboundedSender<String>>>, tokio::sync::Mutex<
std::collections::HashMap<u64, tokio::sync::mpsc::UnboundedSender<String>>,
>,
>, >,
) { ) {
use crate::native::relay::ClientRoute; use crate::native::relay::ClientRoute;
+1 -4
View File
@@ -142,10 +142,7 @@ fn walk(node: &Value, folder: &str, keywords: &[String], out: &mut Vec<Hit>) {
let url = node.get("url").and_then(|v| v.as_str()).unwrap_or(""); let url = node.get("url").and_then(|v| v.as_str()).unwrap_or("");
// Skip non-navigable bookmarks: javascript: bookmarklets and data: // Skip non-navigable bookmarks: javascript: bookmarklets and data:
// URIs aren't pages you can visit, and their bodies can be huge. // URIs aren't pages you can visit, and their bodies can be huge.
if url.is_empty() if url.is_empty() || url.starts_with("javascript:") || url.starts_with("data:") {
|| url.starts_with("javascript:")
|| url.starts_with("data:")
{
return; return;
} }
let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase()); let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase());
+1 -2
View File
@@ -460,8 +460,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT") auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT") && (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|| config.auto_connect.unwrap_or(true)), || config.auto_connect.unwrap_or(true)),
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH") force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH") || env::var("CI").is_ok(),
|| env::var("CI").is_ok(),
session_name: env::var("AGENT_BROWSER_SESSION_NAME") session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok() .ok()
.or(config.session_name), .or(config.session_name),
+1 -1
View File
@@ -1,8 +1,8 @@
mod chat; mod chat;
mod color; mod color;
mod commands; mod commands;
mod connection;
mod connect; mod connect;
mod connection;
mod doctor; mod doctor;
mod findurl; mod findurl;
mod flags; mod flags;
+13 -5
View File
@@ -1525,10 +1525,14 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
// about:blank. Failing here lets the caller surface the real error. // about:blank. Failing here lets the caller surface the real error.
if let Err(e) = mgr if let Err(e) = mgr
.client .client
.send_command("Runtime.evaluate", Some(serde_json::json!({ .send_command(
"expression": "1", "Runtime.evaluate",
"returnByValue": true, Some(serde_json::json!({
})), Some(&session_id)) "expression": "1",
"returnByValue": true,
})),
Some(&session_id),
)
.await .await
{ {
return Err(format!( return Err(format!(
@@ -1818,7 +1822,11 @@ async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
/// Apply stealth to the active page session (initial connect/launch). /// Apply stealth to the active page session (initial connect/launch).
async fn apply_stealth_to_browser(state: &DaemonState) { async fn apply_stealth_to_browser(state: &DaemonState) {
let session_id = match state.browser.as_ref().and_then(|m| m.active_session_id().ok()) { let session_id = match state
.browser
.as_ref()
.and_then(|m| m.active_session_id().ok())
{
Some(sid) => sid.to_string(), Some(sid) => sid.to_string(),
None => return, None => return,
}; };
+11 -2
View File
@@ -271,7 +271,11 @@ mod tests {
#[test] #[test]
fn identical_fingerprints_score_one() { fn identical_fingerprints_score_one() {
let a = fp("button", "Submit", &[("id", "go"), ("class", "btn primary")]); let a = fp(
"button",
"Submit",
&[("id", "go"), ("class", "btn primary")],
);
assert!((score(&a, &a) - 1.0).abs() < 1e-9); assert!((score(&a, &a) - 1.0).abs() < 1e-9);
} }
@@ -308,7 +312,12 @@ mod tests {
let mut b = fp("button", "OK", &[]); let mut b = fp("button", "OK", &[]);
a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()]; a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()];
// b wrapped in an extra div — DOM path changed but mostly preserved // b wrapped in an extra div — DOM path changed but mostly preserved
b.ancestors = vec!["form#f".into(), "div.wrap".into(), "div.col".into(), "body".into()]; b.ancestors = vec![
"form#f".into(),
"div.wrap".into(),
"div.col".into(),
"body".into(),
];
let s = score(&a, &b); let s = score(&a, &b);
assert!(s > 0.85, "got {s}"); assert!(s > 0.85, "got {s}");
} }
+12 -3
View File
@@ -1094,7 +1094,10 @@ impl BrowserManager {
if !via_relay { if !via_relay {
return None; return None;
} }
let name = DAEMON_SESSION.get().map(String::as_str).unwrap_or("default"); let name = DAEMON_SESSION
.get()
.map(String::as_str)
.unwrap_or("default");
if name.is_empty() { if name.is_empty() {
None None
} else { } else {
@@ -1853,8 +1856,14 @@ mod tests {
#[test] #[test]
fn liveness_transport_error_is_dead_for_both_kinds() { fn liveness_transport_error_is_dead_for_both_kinds() {
// A closed/reset WebSocket is a genuine death — reconnect in both cases. // A closed/reset WebSocket is a genuine death — reconnect in both cases.
assert!(!connection_alive_from_probe(LivenessProbe::TransportError, true)); assert!(!connection_alive_from_probe(
assert!(!connection_alive_from_probe(LivenessProbe::TransportError, false)); LivenessProbe::TransportError,
true
));
assert!(!connection_alive_from_probe(
LivenessProbe::TransportError,
false
));
} }
#[test] #[test]
+12 -13
View File
@@ -813,11 +813,13 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
} }
} }
Err("No running Chrome with remote debugging found. Remote debugging is a \ Err(
"No running Chrome with remote debugging found. Remote debugging is a \
startup flag, not a setting: fully quit Chrome and relaunch it with \ startup flag, not a setting: fully quit Chrome and relaunch it with \
--remote-debugging-port=9222 (then agent-browser auto-connects), or pass \ --remote-debugging-port=9222 (then agent-browser auto-connects), or pass \
--cdp <port>/--launch." --cdp <port>/--launch."
.to_string()) .to_string(),
)
} }
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry. /// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
@@ -861,11 +863,7 @@ async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String
async fn tcp_port_alive(port: u16) -> bool { async fn tcp_port_alive(port: u16) -> bool {
let timeout = Duration::from_secs(1); let timeout = Duration::from_secs(1);
matches!( matches!(
tokio::time::timeout( tokio::time::timeout(timeout, tokio::net::TcpStream::connect(("127.0.0.1", port)),).await,
timeout,
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await,
Ok(Ok(_)) Ok(Ok(_))
) )
} }
@@ -2181,7 +2179,11 @@ mod tests {
let ws_path = "/devtools/browser/test-uuid-1234"; let ws_path = "/devtools/browser/test-uuid-1234";
let result = resolve_cdp_from_active_port(port, ws_path).await; let result = resolve_cdp_from_active_port(port, ws_path).await;
assert!(result.is_ok(), "should succeed when port is live: {:?}", result); assert!(
result.is_ok(),
"should succeed when port is live: {:?}",
result
);
assert_eq!( assert_eq!(
result.unwrap(), result.unwrap(),
format!("ws://127.0.0.1:{}{}", port, ws_path), format!("ws://127.0.0.1:{}{}", port, ws_path),
@@ -2207,11 +2209,8 @@ mod tests {
// The liveness check connects then drops without writing anything. // The liveness check connects then drops without writing anything.
// Assert we receive no WebSocket upgrade bytes (EOF / no data). // Assert we receive no WebSocket upgrade bytes (EOF / no data).
let mut buf = [0u8; 128]; let mut buf = [0u8; 128];
let read = tokio::time::timeout( let read =
Duration::from_millis(500), tokio::time::timeout(Duration::from_millis(500), stream.read(&mut buf)).await;
stream.read(&mut buf),
)
.await;
match read { match read {
Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"), Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"),
Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine
+5
View File
@@ -346,6 +346,11 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
// Spawns a real child process and binds a TCP server with timing-based
// readiness assumptions; flaky under CI load (intermittent "exited before
// CDP became ready" / connection-refused races). Run locally with
// `--ignored` when touching lightpanda startup.
#[ignore = "process spawn + socket timing race, flaky in CI"]
async fn waits_for_ready_without_logs() { async fn waits_for_ready_without_logs() {
let port = unused_port(); let port = unused_port();
tokio::spawn(serve_json_version_once_after_delay( tokio::spawn(serve_json_version_once_after_delay(
+5 -7
View File
@@ -276,12 +276,8 @@ pub async fn resolve_element_center(
// //
// Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip. // Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip.
if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") { if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") {
if let Err(e) = verify_click_target(client, effective_session_id, active_id, &ref_id, x, y)
verify_click_target(client, effective_session_id, active_id, &ref_id, x, y) .await?;
.await
{
return Err(e);
}
} }
return Ok((x, y, effective_session_id.to_string())); return Ok((x, y, effective_session_id.to_string()));
} }
@@ -586,7 +582,9 @@ async fn verify_click_target(
else { else {
return Ok(()); return Ok(());
}; };
let Ok(resolved) = resolve_resp else { return Ok(()) }; let Ok(resolved) = resolve_resp else {
return Ok(());
};
let Some(object_id) = resolved let Some(object_id) = resolved
.get("object") .get("object")
.and_then(|o| o.get("objectId")) .and_then(|o| o.get("objectId"))
+25 -5
View File
@@ -24,10 +24,24 @@ pub async fn click(
// inside the viewport. Without this, an element below the fold (or revealed // inside the viewport. Without this, an element below the fold (or revealed
// after scroll/popup) yields off-viewport coordinates and the click lands on // after scroll/popup) yields off-viewport coordinates and the click lands on
// whatever currently occupies that point. Best-effort: ignore failures. // whatever currently occupies that point. Best-effort: ignore failures.
scroll_into_view_if_needed(client, session_id, ref_map, selector_or_ref, iframe_sessions).await; scroll_into_view_if_needed(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
if mode == "dom" { if mode == "dom" {
return dom_click(client, session_id, ref_map, selector_or_ref, iframe_sessions).await; return dom_click(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
} }
let resolved = resolve_element_center( let resolved = resolve_element_center(
@@ -57,9 +71,15 @@ pub async fn click(
"[click] coordinate click failed ({e}); falling back to DOM dispatch \ "[click] coordinate click failed ({e}); falling back to DOM dispatch \
(set AGENT_BROWSER_CLICK_MODE=coord to disable)" (set AGENT_BROWSER_CLICK_MODE=coord to disable)"
); );
dom_click(client, session_id, ref_map, selector_or_ref, iframe_sessions) dom_click(
.await client,
.map_err(|dom_err| format!("{e}\n(DOM-dispatch fallback also failed: {dom_err})")) session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await
.map_err(|dom_err| format!("{e}\n(DOM-dispatch fallback also failed: {dom_err})"))
} }
} }
} }
+2 -2
View File
@@ -27,12 +27,12 @@ pub mod policy;
#[allow(dead_code)] #[allow(dead_code)]
pub mod providers; pub mod providers;
#[allow(dead_code)] #[allow(dead_code)]
pub mod relay;
#[allow(dead_code)]
pub mod react; pub mod react;
#[allow(dead_code)] #[allow(dead_code)]
pub mod recording; pub mod recording;
#[allow(dead_code)] #[allow(dead_code)]
pub mod relay;
#[allow(dead_code)]
pub mod screenshot; pub mod screenshot;
#[allow(dead_code)] #[allow(dead_code)]
pub mod snapshot; pub mod snapshot;
+39 -10
View File
@@ -129,12 +129,18 @@ impl RelayState {
ClientRoute::Local(json!({ "id": id, "result": {} })) ClientRoute::Local(json!({ "id": id, "result": {} }))
} }
"Target.getTargets" => { "Target.getTargets" => {
let infos: Vec<Value> = let infos: Vec<Value> = self
self.targets.values().map(|t| t.target_info.clone()).collect(); .targets
.values()
.map(|t| t.target_info.clone())
.collect();
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } })) ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
} }
"Target.attachToTarget" => { "Target.attachToTarget" => {
let target_id = params.get("targetId").and_then(|t| t.as_str()).unwrap_or(""); let target_id = params
.get("targetId")
.and_then(|t| t.as_str())
.unwrap_or("");
match self.targets.get(target_id) { match self.targets.get(target_id) {
Some(entry) => ClientRoute::Local( Some(entry) => ClientRoute::Local(
json!({ "id": id, "result": { "sessionId": entry.session_id } }), json!({ "id": id, "result": { "sessionId": entry.session_id } }),
@@ -237,7 +243,10 @@ impl RelayState {
.to_string(); .to_string();
self.targets.insert( self.targets.insert(
tid.to_string(), tid.to_string(),
TargetEntry { session_id: sid, target_info: info.clone() }, TargetEntry {
session_id: sid,
target_info: info.clone(),
},
); );
} }
} }
@@ -305,7 +314,10 @@ mod tests {
fn learns_target_from_attached_event_and_does_not_forward_it() { fn learns_target_from_attached_event_and_does_not_forward_it() {
let mut s = RelayState::new(); let mut s = RelayState::new();
let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok"); let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok");
assert!(out.is_empty(), "attachedToTarget should be consumed, not forwarded"); assert!(
out.is_empty(),
"attachedToTarget should be consumed, not forwarded"
);
// Now getTargets must report it. // Now getTargets must report it.
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" })); let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
match route { match route {
@@ -384,8 +396,14 @@ mod tests {
fn reply_routes_back_to_the_issuing_client_with_original_id() { fn reply_routes_back_to_the_issuing_client_with_original_id() {
let mut s = RelayState::new(); let mut s = RelayState::new();
// Two clients each send a command that happens to share original id 1. // Two clients each send a command that happens to share original id 1.
let r1 = s.route_client_command(100, &json!({ "id": 1, "method": "Page.navigate", "params": {} })); let r1 = s.route_client_command(
let r2 = s.route_client_command(200, &json!({ "id": 1, "method": "Page.reload", "params": {} })); 100,
&json!({ "id": 1, "method": "Page.navigate", "params": {} }),
);
let r2 = s.route_client_command(
200,
&json!({ "id": 1, "method": "Page.reload", "params": {} }),
);
let g1 = match r1 { let g1 = match r1 {
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(), ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
_ => panic!(), _ => panic!(),
@@ -419,7 +437,10 @@ mod tests {
#[test] #[test]
fn forward_command_error_is_wrapped_and_routed() { fn forward_command_error_is_wrapped_and_routed() {
let mut s = RelayState::new(); let mut s = RelayState::new();
let r = s.route_client_command(5, &json!({ "id": 3, "method": "Page.navigate", "params": {} })); let r = s.route_client_command(
5,
&json!({ "id": 3, "method": "Page.navigate", "params": {} }),
);
let gid = match r { let gid = match r {
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(), ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
_ => panic!(), _ => panic!(),
@@ -459,7 +480,10 @@ mod tests {
#[test] #[test]
fn drop_client_clears_its_pending() { fn drop_client_clears_its_pending() {
let mut s = RelayState::new(); let mut s = RelayState::new();
let r = s.route_client_command(9, &json!({ "id": 1, "method": "Page.navigate", "params": {} })); let r = s.route_client_command(
9,
&json!({ "id": 1, "method": "Page.navigate", "params": {} }),
);
let gid = match r { let gid = match r {
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(), ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
_ => panic!(), _ => panic!(),
@@ -478,7 +502,12 @@ mod tests {
let mut s = RelayState::new(); let mut s = RelayState::new();
let req = json!({ "type": "req", "id": "c1", "method": "connect", "params": { "auth": { "token": "good" } } }); let req = json!({ "type": "req", "id": "c1", "method": "connect", "params": { "auth": { "token": "good" } } });
let ok = s.handle_ext_message(&req, "good"); let ok = s.handle_ext_message(&req, "good");
assert_eq!(ok, vec![RelayOut::ToExt(json!({ "type": "res", "id": "c1", "ok": true }))]); assert_eq!(
ok,
vec![RelayOut::ToExt(
json!({ "type": "res", "id": "c1", "ok": true })
)]
);
let bad = s.handle_ext_message(&req, "different"); let bad = s.handle_ext_message(&req, "different");
match &bad[0] { match &bad[0] {
+1 -1
View File
@@ -2,11 +2,11 @@ use std::collections::HashMap;
use serde_json::Value; use serde_json::Value;
use super::adaptive::ElementFingerprint;
use super::cdp::client::CdpClient; use super::cdp::client::CdpClient;
use super::cdp::types::{ use super::cdp::types::{
AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult, AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
}; };
use super::adaptive::ElementFingerprint;
use super::element::{resolve_ax_session, RefMap}; use super::element::{resolve_ax_session, RefMap};
const INTERACTIVE_ROLES: &[&str] = &[ const INTERACTIVE_ROLES: &[&str] = &[
+6 -6
View File
@@ -186,9 +186,7 @@ fn resolve_timezone(locale: Option<&str>) -> Option<String> {
return None; return None;
} }
if raw.eq_ignore_ascii_case("auto") { if raw.eq_ignore_ascii_case("auto") {
return locale return locale.and_then(locale_default_timezone).map(str::to_string);
.and_then(locale_default_timezone)
.map(str::to_string);
} }
Some(raw.to_string()) Some(raw.to_string())
} }
@@ -265,8 +263,7 @@ pub fn strip_source_url_labels(input: &str) -> String {
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap(); let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
let output = re_line.replace_all(input, ""); let output = re_line.replace_all(input, "");
// Remove /*# sourceURL=...*/ block comments // Remove /*# sourceURL=...*/ block comments
let re_block = let re_block = regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
re_block.replace_all(&output, "").to_string() re_block.replace_all(&output, "").to_string()
} }
@@ -370,7 +367,10 @@ mod timezone_tests {
assert_eq!(resolve_timezone(Some("en-US")), None); assert_eq!(resolve_timezone(Some("en-US")), None);
std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto"); std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto");
assert_eq!(resolve_timezone(Some("ja-JP")), Some("Asia/Tokyo".to_string())); assert_eq!(
resolve_timezone(Some("ja-JP")),
Some("Asia/Tokyo".to_string())
);
assert_eq!(resolve_timezone(Some("xx-YY")), None); assert_eq!(resolve_timezone(Some("xx-YY")), None);
assert_eq!(resolve_timezone(None), None); assert_eq!(resolve_timezone(None), None);
+3 -1
View File
@@ -84,7 +84,9 @@ fn embedded_skills_root() -> Option<PathBuf> {
let _ = fs::create_dir_all(base.join("skills")); let _ = fs::create_dir_all(base.join("skills"));
let _ = fs::create_dir_all(base.join("skill-data")); let _ = fs::create_dir_all(base.join("skill-data"));
if EMBEDDED_SKILLS.extract(base.join("skills")).is_err() if EMBEDDED_SKILLS.extract(base.join("skills")).is_err()
|| EMBEDDED_SKILL_DATA.extract(base.join("skill-data")).is_err() || EMBEDDED_SKILL_DATA
.extract(base.join("skill-data"))
.is_err()
{ {
return None; return None;
} }
+4 -1
View File
@@ -62,7 +62,10 @@ pub fn run_upgrade() {
color::success_indicator() color::success_indicator()
); );
} else { } else {
eprintln!("{} Upgrade failed. Install manually:", color::error_indicator()); eprintln!(
"{} Upgrade failed. Install manually:",
color::error_indicator()
);
eprintln!(" curl -fsSL {} | sh", INSTALL_URL); eprintln!(" curl -fsSL {} | sh", INSTALL_URL);
exit(1); exit(1);
} }
-7
View File
@@ -27,17 +27,10 @@ if (!cargoVersionMatch) {
const cargoVersion = cargoVersionMatch[1]; const cargoVersion = cargoVersionMatch[1];
// Read dashboard package.json version
const dashboardPkg = JSON.parse(readFileSync(join(rootDir, 'packages/dashboard/package.json'), 'utf-8'));
const dashboardVersion = dashboardPkg.version;
const mismatches = []; const mismatches = [];
if (packageVersion !== cargoVersion) { if (packageVersion !== cargoVersion) {
mismatches.push(` cli/Cargo.toml: ${cargoVersion}`); mismatches.push(` cli/Cargo.toml: ${cargoVersion}`);
} }
if (packageVersion !== dashboardVersion) {
mismatches.push(` packages/dashboard: ${dashboardVersion}`);
}
if (mismatches.length > 0) { if (mismatches.length > 0) {
console.error('Version mismatch detected!'); console.error('Version mismatch detected!');
+9 -5
View File
@@ -51,11 +51,15 @@ hand-constructed URL often doesn't.
When the task needs the user's *live* logged-in window (their real session, the When the task needs the user's *live* logged-in window (their real session, the
window they're looking at — not a fresh browser), use the extension connect flow: window they're looking at — not a fresh browser), use the extension connect flow:
`agent-browser extension install` once, load `extensions/ab-connect` in `agent-browser extension install` once, load `extensions/ab-connect` in
`chrome://extensions` once (a GUI step you can perform with a **computer-use / `chrome://extensions` once (it shows up as **agent-browser-stealth**; a GUI step
GUI-automation tool** like the `cua-driver` skill — see you can perform with a **computer-use / GUI-automation tool** like the
`references/commands.md` → "Drive your real, logged-in Chrome"), then `cua-driver` skill — see `references/commands.md` → "Drive your real, logged-in
`agent-browser extension connect`. After that it's zero-confirmation, zero-token Chrome"). Once the extension is loaded, plain `agent-browser open <url>`
CLI. Use `--launch` instead when a fresh, isolated browser is fine. auto-connects through it — `auto_connect_cdp` **prefers the live extension relay
over a raw `--remote-debugging-port`**, so Chrome 136+'s "Allow remote debugging?"
consent popup never fires. `agent-browser extension connect` is the explicit form
of the same path. Zero-confirmation, zero-token. Use `--launch` instead when a
fresh, isolated browser is fine.
Each `--session` that connects gets its **own colored Chrome tab group** (named Each `--session` that connects gets its **own colored Chrome tab group** (named
after the session) and drives only its own tabs — multiple agents share the one after the session) and drives only its own tabs — multiple agents share the one
+8 -1
View File
@@ -334,7 +334,14 @@ Then load the extension **once** — this is a GUI step (Chrome's `chrome://exte
is privileged; the CLI can't load an unpacked extension): is privileged; the CLI can't load an unpacked extension):
> chrome://extensions → enable **Developer mode** (top-right) → **Load unpacked** > chrome://extensions → enable **Developer mode** (top-right) → **Load unpacked**
> select `<repo>/extensions/ab-connect` > select `<repo>/extensions/ab-connect` (it appears in the list as
> **agent-browser-stealth**)
Once loaded, the relay goes live and plain `agent-browser open <url>` connects
through it automatically — `auto_connect_cdp` prefers the live extension relay
over a raw `--remote-debugging-port`, so Chrome 136+'s "Allow remote debugging?"
consent popup never appears. `agent-browser extension connect` is the explicit
form of the same path.
**You can do this load step yourself with a computer-use / GUI-automation tool** **You can do this load step yourself with a computer-use / GUI-automation tool**
(e.g. the `cua-driver` skill) — drive `chrome://extensions`, toggle Developer (e.g. the `cua-driver` skill) — drive `chrome://extensions`, toggle Developer