← Commit history

v2.0.648 (insiders): pup-bridge#104 fetch_url (CORS credentials fallback, binary-safe body + sha256); #120 trusted input on the extension lane; #112 throwaway iso- profiles; #90 slow opens answer loading:true

John Lauer ·d6fa242c82 ·1mo ago ·parent 5bfc30d
6 files changed +141−24
rust/Cargo.lock+12−12
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-pup-bridge"-version = "2.0.647"+version = "2.0.648" dependencies = [  "pup-browser",  "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [  [[package]] name = "pup-browser"-version = "2.0.647"+version = "2.0.648" dependencies = [  "pup-core",  "pup-host",@@ -667,7 +667,7 @@ dependencies = [  [[package]] name = "pup-cdp"-version = "2.0.647"+version = "2.0.648" dependencies = [  "serde_json",  "thiserror",@@ -677,7 +677,7 @@ dependencies = [  [[package]] name = "pup-core"-version = "2.0.647"+version = "2.0.648" dependencies = [  "serde",  "serde_json",@@ -687,7 +687,7 @@ dependencies = [  [[package]] name = "pup-dashboard"-version = "2.0.647"+version = "2.0.648" dependencies = [  "pup-cdp",  "pup-core",@@ -700,7 +700,7 @@ dependencies = [  [[package]] name = "pup-host"-version = "2.0.647"+version = "2.0.648" dependencies = [  "serde",  "serde_json",@@ -709,14 +709,14 @@ dependencies = [  [[package]] name = "pup-host-mac"-version = "2.0.647"+version = "2.0.648" dependencies = [  "pup-host", ]  [[package]] name = "pup-host-win"-version = "2.0.647"+version = "2.0.648" dependencies = [  "pup-core",  "pup-host",@@ -725,7 +725,7 @@ dependencies = [  [[package]] name = "pup-identity"-version = "2.0.647"+version = "2.0.648" dependencies = [  "fontdue",  "image",@@ -735,7 +735,7 @@ dependencies = [  [[package]] name = "pup-session"-version = "2.0.647"+version = "2.0.648" dependencies = [  "pup-core",  "serde",@@ -745,7 +745,7 @@ dependencies = [  [[package]] name = "pup-vault"-version = "2.0.647"+version = "2.0.648" dependencies = [  "pup-core",  "pup-host",@@ -756,7 +756,7 @@ dependencies = [  [[package]] name = "pup-verbs"-version = "2.0.647"+version = "2.0.648" dependencies = [  "base64",  "png 0.17.16",
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session",  # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]-version = "2.0.647"+version = "2.0.648" edition = "2021" license = "MIT" 
rust/verbs/src/input.rs+76−10
@@ -357,6 +357,30 @@ pub fn input_dispatch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };     let kind = args.get("type").and_then(|x| x.as_str()).unwrap_or("");     let (x, y) = (args.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0), args.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0));+    // 2.0.648 (pup-bridge#120): TRUSTED input on the extension lane. The worker's "cdp" action drives the same+    // Input.dispatchMouseEvent / dispatchKeyEvent through nbe's DevTools driver (chrome.debugger), so the event+    // is as trusted as the CDP lane's: it lands in cross-origin frames (the reCAPTCHA iframe) that a page-side+    // click() can never reach. Before this the lane answered lane_needs_cdp and the user's rule (never CDP for a+    // captcha, never hand it to the human) left no verb at all.+    if crate::transport::is_ext(&s) {+        let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+        let send = |method: &str, p: Value| -> Result<(), Value> { crate::transport::cdp_raw(&lane, &t, method, p, 15).map(|_| ()) };+        let btn = args.get("button").and_then(|v| v.as_str()).unwrap_or("left").to_string();+        let r = match kind {+            "click" => send("Input.dispatchMouseEvent", json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" }))+                .and_then(|_| send("Input.dispatchMouseEvent", json!({ "type": "mousePressed", "x": x, "y": y, "button": btn, "clickCount": 1 })))+                .and_then(|_| { std::thread::sleep(Duration::from_millis(40 + (x as u64 % 50))); send("Input.dispatchMouseEvent", json!({ "type": "mouseReleased", "x": x, "y": y, "button": btn, "clickCount": 1 })) }),+            "mouseMoved" | "mousePressed" | "mouseReleased" | "mouseWheel" => {+                let mut p = json!({ "type": kind, "x": x, "y": y, "button": if kind == "mouseMoved" { "none".to_string() } else { btn.clone() }, "clickCount": 1 });+                if kind == "mouseWheel" { p["deltaX"] = json!(args.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0)); p["deltaY"] = json!(args.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0)); }+                send("Input.dispatchMouseEvent", p)+            }+            "keyDown" | "keyUp" | "rawKeyDown" | "char" => send("Input.dispatchKeyEvent", json!({ "type": kind, "key": args.get("key"), "text": args.get("text"), "code": args.get("code") })),+            _ => Err(err("bad_type", "type must be click | mouseMoved | mousePressed | mouseReleased | mouseWheel | keyDown | keyUp | rawKeyDown | char", None)),+        };+        if let Err(e) = r { return e; }+        return out(&s, &t, json!({ "dispatched": kind, "x": x, "y": y, "trusted": true, "lane": "ext" }));+    }     let r = match kind {         "click" => mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, 0).and_then(|_| click_once(ctx, &b.cdp, &t.cdp_session, x, y, "left", 1, 0)),         "mouseMoved" | "mousePressed" | "mouseReleased" | "mouseWheel" => {@@ -388,23 +412,65 @@ pub fn fetch_url(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };     let Some(url) = args.get("url").and_then(|x| x.as_str()) else { return err("url_required", "pup_fetch_url requires {url}", None) };     let method = args.get("method").and_then(|x| x.as_str()).unwrap_or("GET");-    let expr = format!(r#"(async () => {{ try {{ const r = await fetch({u}, {{ method: {m}, credentials: 'include', headers: {h} }}); const ct = r.headers.get('content-type') || ''; const text = await r.text(); return {{ ok: r.ok, status: r.status, contentType: ct, length: text.length, text: text.slice(0, 1000000) }}; }} catch (e) {{ return {{ error: e.message }}; }} }})()"#, u = js_str(url), m = js_str(method), h = args.get("headers").cloned().unwrap_or(json!({})));-    // 2.0.644 (pup-bridge#119): on the extension lane run the same fetch through the lane's eval (it used to go-    // to a CDP session this lane does not have and hung 40 s), and report a cancelled HTTP auth challenge as-    // no_credential instead of a hang.+    // 2.0.648 (pup-bridge#104): two defects, both measured on ConfRoomROG against assets.nexperia.com.+    //  1. credentials:'include' on a cross-origin fetch is refused by the browser when the server answers+    //     Access-Control-Allow-Origin: * ("Failed to fetch"), while the reporter's plain in-page fetch worked.+    //     Try with credentials first (a gated file needs the cookie), and on a TypeError retry without.+    //  2. r.text() destroyed binary bodies (a PDF came back as mangled text). Read an ArrayBuffer, hand it+    //     back base64, and let pup write the bytes for saveTo and report the SHA-256 the caller can verify.+    let expr = format!(r#"(async () => {{ const one = async (cred) => {{ const r = await fetch({u}, {{ method: {m}, credentials: cred, headers: {h} }}); const ct = r.headers.get('content-type') || ''; const buf = await r.arrayBuffer(); const bytes = new Uint8Array(buf); let bin = ''; for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return {{ ok: r.ok, status: r.status, contentType: ct, length: bytes.length, base64: btoa(bin), credentials: cred, finalUrl: r.url }}; }}; try {{ try {{ return await one('include'); }} catch (e1) {{ if (!(e1 instanceof TypeError)) throw e1; try {{ return await one('omit'); }} catch (e2) {{ return {{ error: e2.message, firstError: e1.message, stage: 'browser-fetch', pageOrigin: location.origin }}; }} }} }} catch (e) {{ return {{ error: e.message, stage: 'browser-fetch', pageOrigin: location.origin }}; }} }})()"#, u = js_str(url), m = js_str(method), h = args.get("headers").cloned().unwrap_or(json!({})));     let ext = crate::transport::is_ext(&s);     let tab_num = crate::transport::ext_id(&t).as_i64();     if ext { if let Some((host, false)) = tab_num.and_then(crate::vault::unanswered_challenge) { return crate::vault::no_credential_error(&host, false); } }     let res: Result<Value, String> = if ext {         match crate::transport::lane_for(ctx, &s) { Ok(lane) => crate::transport::evaluate(&lane, &t, &expr).map_err(|e| e["error"].as_str().unwrap_or("eval failed").to_string()), Err(e) => Err(e["error"].as_str().unwrap_or("no lane").to_string()) }-    } else { b.cdp.evaluate(&t.cdp_session, &expr, Duration::from_secs(40)).map_err(|e| e.to_string()) };+    } else { b.cdp.evaluate(&t.cdp_session, &expr, Duration::from_secs(60)).map_err(|e| e.to_string()) };     if ext { if let Some((host, answered)) = tab_num.and_then(crate::vault::unanswered_challenge) { if !answered { return crate::vault::no_credential_error(&host, false); } } }-    match res {-        Ok(v) if v.get("error").is_some() => err("fetch_failed", format!("pup_fetch_url failed: {}", v["error"].as_str().unwrap_or("?")), Some("The page's origin must be allowed to fetch that URL (CORS); navigate to the URL instead when it is cross-origin.")),-        Ok(v) if v.get("status").is_some() => ok(json!({ "success": true, "output": v.to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "url": url, "httpStatus": v["status"], "contentType": v["contentType"], "length": v["length"] })),-        Ok(v) => err("fetch_failed", format!("pup_fetch_url: unexpected evaluate result {}", v.to_string().chars().take(200).collect::<String>()), None),-        Err(e) => err("fetch_failed", e.to_string(), None),+    let v = match res {+        Ok(v) => v,+        Err(e) => return pup_core::merge(err("fetch_failed", e, None), json!({ "stage": "evaluate", "tabId": t.tab_id, "pageUrl": t.url })),+    };+    if let Some(e) = v.get("error").and_then(|e| e.as_str()) {+        return pup_core::merge(err("fetch_failed", format!("pup_fetch_url failed in the page: {e}"), Some("The fetch ran inside the named tab, with credentials and then without. A TypeError from both means the page's origin may not read that URL (CORS) or the network refused; navigate the tab to the URL instead, or fetch from a tab on the file's own origin.")),+            json!({ "stage": v["stage"].clone(), "pageOrigin": v["pageOrigin"].clone(), "firstError": v["firstError"].clone(), "tabId": t.tab_id, "pageUrl": t.url }));+    }+    let Some(status) = v.get("status").and_then(|x| x.as_u64()) else { return err("fetch_failed", format!("pup_fetch_url: unexpected evaluate result {}", v.to_string().chars().take(200).collect::<String>()), None) };+    let bytes = v["base64"].as_str().map(b64_decode).unwrap_or_default();+    let ct = v["contentType"].as_str().unwrap_or("").to_string();+    let textual = ct.starts_with("text/") || ct.contains("json") || ct.contains("xml") || ct.contains("javascript");+    let sha = sha256_hex(&bytes);+    let mut out_v = json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "url": url, "finalUrl": v["finalUrl"], "httpStatus": status, "contentType": ct, "bytes": bytes.len(), "sha256": sha, "credentials": v["credentials"], "tabId": t.tab_id });+    if let Some(path) = args.get("saveTo").and_then(|x| x.as_str()) {+        // the caller's path is on the CONTAINER, not the desktop: hand the bytes back and let the CLI write them+        out_v["saveTo"] = json!(path);+        out_v["bodyBase64"] = v["base64"].clone();+        out_v["_hint"] = json!("bodyBase64 holds the file; pup runs on the desktop and cannot write your container path. Decode it to saveTo on your side and compare sha256.");+    } else if textual { out_v["text"] = json!(String::from_utf8_lossy(&bytes).chars().take(1_000_000).collect::<String>()); out_v["output"] = out_v["text"].clone(); }+    else { out_v["bodyBase64"] = v["base64"].clone(); out_v["_hint"] = json!("Binary body: bodyBase64 holds it (sha256 above)."); }+    ok(out_v)+}++fn b64_decode(s: &str) -> Vec<u8> {+    let t = |c: u8| -> Option<u32> { match c { b'A'..=b'Z' => Some((c - b'A') as u32), b'a'..=b'z' => Some((c - b'a' + 26) as u32), b'0'..=b'9' => Some((c - b'0' + 52) as u32), b'+' => Some(62), b'/' => Some(63), _ => None } };+    let mut out = Vec::with_capacity(s.len() * 3 / 4); let mut acc = 0u32; let mut n = 0;+    for &c in s.as_bytes() { if c == b'=' { break; } let Some(v) = t(c) else { continue }; acc = (acc << 6) | v; n += 6; if n >= 8 { n -= 8; out.push(((acc >> n) & 0xff) as u8); } }+    out+}++fn sha256_hex(data: &[u8]) -> String {+    // minimal SHA-256 (no crate): a fetch verb must be able to say what it fetched+    const K: [u32; 64] = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];+    let mut h: [u32; 8] = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];+    let mut msg = data.to_vec(); let bitlen = (data.len() as u64) * 8; msg.push(0x80); while msg.len() % 64 != 56 { msg.push(0); } msg.extend_from_slice(&bitlen.to_be_bytes());+    for chunk in msg.chunks(64) {+        let mut w = [0u32; 64];+        for i in 0..16 { w[i] = u32::from_be_bytes([chunk[4*i], chunk[4*i+1], chunk[4*i+2], chunk[4*i+3]]); }+        for i in 16..64 { let s0 = w[i-15].rotate_right(7) ^ w[i-15].rotate_right(18) ^ (w[i-15] >> 3); let s1 = w[i-2].rotate_right(17) ^ w[i-2].rotate_right(19) ^ (w[i-2] >> 10); w[i] = w[i-16].wrapping_add(s0).wrapping_add(w[i-7]).wrapping_add(s1); }+        let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);+        for i in 0..64 { let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); let ch = (e & f) ^ (!e & g); let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]); let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); let maj = (a & b) ^ (a & c) ^ (b & c); let t2 = s0.wrapping_add(maj); hh = g; g = f; f = e; e = d.wrapping_add(t1); d = c; c = b; b = a; a = t1.wrapping_add(t2); }+        h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b); h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d); h[4] = h[4].wrapping_add(e); h[5] = h[5].wrapping_add(f); h[6] = h[6].wrapping_add(g); h[7] = h[7].wrapping_add(hh);     }+    h.iter().map(|x| format!("{x:08x}")).collect() }  pub fn maximize(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
rust/verbs/src/lib.rs+36−1
@@ -1137,7 +1137,14 @@ fn birth_rect_css(cdp: &Cdp, cdp_session: &str, ctx: &Ctx) -> (i32, i32, i32, i3 fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let t0 = Instant::now();     let url = args.get("url").and_then(|u| u.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "about:blank".into());-    let profile = args.get("profile").and_then(|p| p.as_str()).unwrap_or("adom-you").to_string();+    // 2.0.648 (pup-bridge#112): isolated:true MINTS a throwaway profile. It used to bind adom-you and only pick the+    // anonymous tile, so a "throwaway" login persisted into the shared jar and an untrusted page ran on the user's+    // real cookies. The directory is removed when the window's browser is gone (close_window).+    let profile = match args.get("profile").and_then(|p| p.as_str()) {+        Some(p) => p.to_string(),+        None if args.get("isolated").and_then(|b| b.as_bool()) == Some(true) => format!("iso-{:x}{:x}", pup_core::now_ms() & 0xffffff, std::process::id() & 0xffff),+        None => "adom-you".to_string(),+    };     if args.get("isolated").and_then(|b| b.as_bool()) == Some(true) && args.get("isolationReason").and_then(|r| r.as_str()).map(|r| r.len() >= 10) != Some(true) {         return err("isolation_reason_required", "isolated:true needs an isolationReason (min 10 chars): why must this window NOT be adom-you?", Some("Surf as adom-you by default (its logins accumulate). Isolation is for logged-out tests, acting as a different user, or untrusted pages: say which."));     }@@ -1628,6 +1635,7 @@ fn spawn_zwatch(ctx: &Ctx, sid: &str, before: std::collections::HashSet<i64>) {     }).ok(); } pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owner: Option<&str>, url: &str) -> Value {+    let t_verb = Instant::now();   // 2.0.648 (pup-bridge#90): the early-return budget starts here     if let Some(s) = ctx.sessions.get(sid) {         if transport::is_ext(&s) {             if url != "about:blank" { return tabs::open_tab(ctx, caller, &json!({ "window": sid, "url": url })); }@@ -1799,6 +1807,27 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne         // relaunched every session on disk, including six windows John had closed by hand (AdomLapper 21:41,         // 2026-09-15). That was also the "worker stalls right after a cold launch" mystery.         ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+        // 2.0.648 (pup-bridge#90, Bridge SDK #59): a slow box can spend the whole 60 s ab budget in the cold launch+        // alone; the window EXISTS by now (bound, parked, owned), so answer with loading:true and finish the+        // navigate, fit and identity in the background instead of letting ab time the verb out after success.+        if t_verb.elapsed() > Duration::from_secs(35) {+            if let Some(a) = ctx.arc() {+                let (sid2, url2, tab2, caller2) = (sid.to_string(), url.to_string(), chrome_tab, caller.clone());+                std::thread::Builder::new().name("open-finish".into()).spawn(move || {+                    let ctx2: &Ctx = &a;+                    if let Ok(nav) = extdrive::run(json!({ "action": "navigate", "url": url2, "tabId": tab2, "waitMs": 25000 }), Duration::from_secs(30)) {+                        ctx2.sessions.with_mut(&sid2, |x| if let Some(t0) = x.tabs.get_mut(0) { if let Some(u) = nav["url"].as_str() { if !u.is_empty() { t0.url = u.to_string(); } } if let Some(ti) = nav["title"].as_str() { if !ti.is_empty() { t0.title = ti.to_string(); } } });+                        if let Some(ss) = ctx2.sessions.get(&sid2) { ctx2.sessions.save_file(&ss); }+                    }+                    extdrive::fit_now();+                    identity::stamp_opts(&ctx2, &sid2, &caller2, true, true);+                    pup_core::log(format!("[window] \"{sid2}\" slow open finished in the background ({} s after the verb began)", t_verb.elapsed().as_secs()));+                }).ok();+            }+            return ok(json!({ "sessionId": sid, "window": sid, "aiThread": s.owner, "tabId": t.tab_id, "hwnd": s.hwnd, "windowId": s.window_id, "transport": "ext", "loading": true,+                "progress": { "phase": "navigating", "elapsedSec": t_verb.elapsed().as_secs(), "percent": 70 },+                "_hint": "This box is slow: the window exists, is yours and is parked in the background, and the page is still loading. Poll pup_window_info {} (url/title fill in) or pup_readiness (active[]) rather than opening again; a second open with the same window label is refused as owned." }));+        }         let nav = match extdrive::run(json!({ "action": "navigate", "url": url, "tabId": chrome_tab, "waitMs": 25000 }), Duration::from_secs(30)) {             Ok(v) => v,             Err(e) => {@@ -1950,6 +1979,12 @@ pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         }         // last window on that profile: let the browser go (Node: killProfileBrowserIfUnused)         if ctx.sessions.for_profile(&s.profile).is_empty() {+            if s.profile.starts_with("iso-") {+                // 2.0.648 (pup-bridge#112): a throwaway profile dies with its last window; the browser is ended+                // just below, so remove the directory after it has let go (a few tries, then leave it for prewarm).+                let dir = pup_browser::profiles_dir(&*ctx.host).join(&s.profile); let sid = s.id.clone();+                std::thread::spawn(move || { for _ in 0..10 { std::thread::sleep(Duration::from_millis(1500)); if std::fs::remove_dir_all(&dir).is_ok() || !dir.exists() { pup_core::log(format!("[profile] \"{sid}\": throwaway profile {} removed", dir.display())); return; } } pup_core::log(format!("[profile] \"{sid}\": throwaway profile {} still held; left on disk", dir.display())); });+            }             if let Some(b) = ctx.browsers.lock().unwrap().remove(&s.profile) {                 let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); b.cdp.close();                 // give Chrome a moment to exit on its own; only then reap a straggler (never a slow one mid-write)
rust/verbs/src/transport.rs+9
@@ -50,6 +50,15 @@ pub fn navigate(lane: &Lane, t: &Tab, url: &str) -> Result<(String, String, bool     } } +/// 2.0.648: one raw DevTools command on either lane (the extension lane routes it through the worker's "cdp"+/// action, nbe's chrome.debugger driver). Used for trusted input on the extension lane (pup-bridge#120).+pub fn cdp_raw(lane: &Lane, t: &Tab, method: &str, params: Value, timeout_s: u64) -> Result<Value, Value> {+    match lane {+        Lane::Cdp(b) => b.cdp.send(Some(&t.cdp_session), method, params, std::time::Duration::from_secs(timeout_s)).map_err(|e| err("cdp_failed", e.to_string(), None)),+        Lane::Ext => run(json!({ "action": "cdp", "method": method, "params": params, "tabId": ext_id(t), "timeoutMs": timeout_s * 1000 }), timeout_s + 5).map(|r| r["result"].clone()),+    }+}+ pub fn evaluate(lane: &Lane, t: &Tab, expr: &str) -> Result<Value, Value> {     match lane {         Lane::Cdp(b) => b.cdp.evaluate(&t.cdp_session, expr, Duration::from_secs(20)).map_err(|e| err("eval_failed", e.to_string(), None)),
skills/pup-lanes/SKILL.md+7
@@ -49,3 +49,10 @@ only CDP can do. pup no longer falls back to CDP on its own for any reason. ## How you tell them apart  The toolbar: four icons is the extension lane, three is CDP (John's own spec for the fourth icon). `pup_list_windows` reports `transport: "ext"` or `"cdp"` per window. `pup_status.extensionLane` shows the lane's health.++## What the extension lane can do since 2.0.648 (it used to say lane_needs_cdp)++- **Trusted input:** `pup_input_dispatch {type:"click"|"mouseMoved"|"mousePressed"|"mouseReleased"|"mouseWheel"|"keyDown"|"keyUp"|"rawKeyDown"|"char", x, y, ...}` works on the extension lane through the worker's DevTools driver, so the event is as trusted as the CDP lane's and lands inside cross-origin frames. Measured 2026-09-19: a click on the reCAPTCHA demo checkbox passed it and issued a token, no image grid. Captchas are driven here, never on the CDP lane and never handed to the human (the user's rule); the image-grid loop (screenshot, pick tiles, trusted tile clicks, Verify) is the caller's script on top of these verbs.+- **`pup_fetch_url`** runs in the named tab on this lane, tries the fetch with credentials and then without (a wildcard CORS header refuses credentials), returns `httpStatus`, `contentType`, `bytes`, `sha256`, and the body as `bodyBase64` (binary-safe) or `text`. pup runs on the desktop: `saveTo` is your container path, so decode `bodyBase64` yourself and compare `sha256`. A cancelled HTTP auth challenge comes back as `no_credential` with the host.+- **`isolated:true`** now mints a throwaway `iso-<id>` profile (reported by `pup_window_info`) and removes it when the window closes. It still needs `lane:"cdp"`: the extension lane is one browser on adom-you (pup-bridge#113 is the per-profile lane).+- **Slow boxes:** a cold `pup_open_window` that would exceed ab's budget answers `ok:true, loading:true` with a progress frame once the window exists and finishes the navigate in the background; poll `pup_window_info` instead of opening again.